From f9b4a211b12d759395c6ebd4b5c324c00052e25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Og=C3=B3rek?= Date: Mon, 25 Jun 2018 15:45:52 +0200 Subject: [PATCH] feat: Use proper object serializer to handle cyclical objects --- packages/utils/src/object.ts | 86 ++++++++++++- packages/utils/test/object.test.ts | 197 ++++++++++++++++++++++++++++- packages/utils/test/tslint.json | 4 +- 3 files changed, 280 insertions(+), 7 deletions(-) diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 491766504ca0..455b58b324bf 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -1,5 +1,85 @@ +/** + * Just an Error object with arbitrary attributes attached to it. + */ +interface ExtendedError extends Error { + [key: string]: any; +} + +/** + * Transforms Error object into an object literal with all it's attributes + * attached to it. + * + * Based on: https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106 + * + * @param error An Error containing all relevant information + * @returns An object with all error properties + */ +function objectifyError(error: ExtendedError): object { + // These properties are implemented as magical getters and don't show up in `for-in` loop + const err: { + stack: string | undefined; + message: string; + name: string; + [key: string]: any; + } = { + message: error.message, + name: error.name, + stack: error.stack, + }; + + for (const i in error) { + if (Object.prototype.hasOwnProperty.call(error, i)) { + err[i] = error[i]; + } + } + + return err; +} + +/** + * Serializer function used as 2nd argument to JSON.serialize in `serialize()` util function. + */ +function serializer(): (key: string, value: any) => any { + const stack: any[] = []; + const keys: string[] = []; + const cycleReplacer = (_: string, value: any) => { + if (stack[0] === value) { + return '[Circular ~]'; + } + return `[Circular ~.${keys.slice(0, stack.indexOf(value)).join('.')}]`; + }; + + return function(this: any, key: string, value: any): any { + let currentValue: any = value; + + if (stack.length > 0) { + const thisPos = stack.indexOf(this); + + if (thisPos !== -1) { + stack.splice(thisPos + 1); + keys.splice(thisPos, Infinity, key); + } else { + stack.push(this); + keys.push(key); + } + + if (stack.indexOf(currentValue) !== -1) { + currentValue = cycleReplacer.call(this, key, currentValue); + } + } else { + stack.push(currentValue); + } + + return currentValue instanceof Error + ? objectifyError(currentValue) + : currentValue; + }; +} + /** * Serializes the given object into a string. + * Like JSON.stringify, but doesn't throw on circular references. + * Based on a `json-stringify-safe` package and modified to handle Errors serialization. * * The object must be serializable, i.e.: * - Only primitive types are allowed (object, array, number, string, boolean) @@ -9,8 +89,7 @@ * @returns A string containing the serialized object. */ export function serialize(object: T): string { - // TODO: Fix cyclic and deep objects - return JSON.stringify(object); + return JSON.stringify(object, serializer()); } /** @@ -21,7 +100,6 @@ export function serialize(object: T): string { * @returns The deserialized object. */ export function deserialize(str: string): T { - // TODO: Handle recursion stubs from serialize return JSON.parse(str) as T; } @@ -59,9 +137,7 @@ export function fill( ): void { const orig = source[name]; source[name] = replacement(orig); - // tslint:disable:no-unsafe-any source[name].__raven__ = true; - // tslint:disable:no-unsafe-any source[name].__orig__ = orig; if (track) { track.push([source, name, orig]); diff --git a/packages/utils/test/object.test.ts b/packages/utils/test/object.test.ts index 9bbb63c96ad5..f4bde6360fe1 100644 --- a/packages/utils/test/object.test.ts +++ b/packages/utils/test/object.test.ts @@ -6,7 +6,6 @@ const MATRIX = [ { name: 'string', object: 'test', serialized: '"test"' }, { name: 'array', object: [1, 'test'], serialized: '[1,"test"]' }, { name: 'object', object: { a: 'test' }, serialized: '{"a":"test"}' }, - // TODO: Add tests for cyclic and deep objects ]; describe('clone()', () => { @@ -18,11 +17,207 @@ describe('clone()', () => { }); describe('serialize()', () => { + function jsonify(obj: object): string { + return JSON.stringify(obj); + } + for (const entry of MATRIX) { test(`serializes a ${entry.name}`, () => { expect(serialize(entry.object)).toEqual(entry.serialized); }); } + + describe('cyclical structures', () => { + it('must stringify circular objects', () => { + const obj = { name: 'Alice' }; + // @ts-ignore + obj.self = obj; + + const json = serialize(obj); + expect(json).toEqual(jsonify({ name: 'Alice', self: '[Circular ~]' })); + }); + + it('must stringify circular objects with intermediaries', () => { + const obj = { name: 'Alice' }; + // @ts-ignore + obj.identity = { self: obj }; + const json = serialize(obj); + expect(json).toEqual( + jsonify({ name: 'Alice', identity: { self: '[Circular ~]' } }), + ); + }); + + it('must stringify circular objects deeper', () => { + const obj = { name: 'Alice', child: { name: 'Bob' } }; + // @ts-ignore + obj.child.self = obj.child; + + expect(serialize(obj)).toEqual( + jsonify({ + name: 'Alice', + child: { name: 'Bob', self: '[Circular ~.child]' }, + }), + ); + }); + + it('must stringify circular objects deeper with intermediaries', () => { + const obj = { name: 'Alice', child: { name: 'Bob' } }; + // @ts-ignore + obj.child.identity = { self: obj.child }; + + expect(serialize(obj)).toEqual( + jsonify({ + name: 'Alice', + child: { name: 'Bob', identity: { self: '[Circular ~.child]' } }, + }), + ); + }); + + it('must stringify circular objects in an array', () => { + const obj = { name: 'Alice' }; + // @ts-ignore + obj.self = [obj, obj]; + + expect(serialize(obj)).toEqual( + jsonify({ + name: 'Alice', + self: ['[Circular ~]', '[Circular ~]'], + }), + ); + }); + + it('must stringify circular objects deeper in an array', () => { + const obj = { + name: 'Alice', + children: [{ name: 'Bob' }, { name: 'Eve' }], + }; + // @ts-ignore + obj.children[0].self = obj.children[0]; + // @ts-ignore + obj.children[1].self = obj.children[1]; + + expect(serialize(obj)).toEqual( + jsonify({ + name: 'Alice', + children: [ + { name: 'Bob', self: '[Circular ~.children.0]' }, + { name: 'Eve', self: '[Circular ~.children.1]' }, + ], + }), + ); + }); + + it('must stringify circular arrays', () => { + const obj: object[] = []; + obj.push(obj); + obj.push(obj); + const json = serialize(obj); + expect(json).toEqual(jsonify(['[Circular ~]', '[Circular ~]'])); + }); + + it('must stringify circular arrays with intermediaries', () => { + const obj: object[] = []; + obj.push({ name: 'Alice', self: obj }); + obj.push({ name: 'Bob', self: obj }); + + expect(serialize(obj)).toEqual( + jsonify([ + { name: 'Alice', self: '[Circular ~]' }, + { name: 'Bob', self: '[Circular ~]' }, + ]), + ); + }); + + it('must stringify repeated objects in objects', () => { + const obj = {}; + const alice = { name: 'Alice' }; + // @ts-ignore + obj.alice1 = alice; + // @ts-ignore + obj.alice2 = alice; + + expect(serialize(obj)).toEqual( + jsonify({ + alice1: { name: 'Alice' }, + alice2: { name: 'Alice' }, + }), + ); + }); + + it('must stringify repeated objects in arrays', () => { + const alice = { name: 'Alice' }; + const obj = [alice, alice]; + const json = serialize(obj); + expect(json).toEqual(jsonify([{ name: 'Alice' }, { name: 'Alice' }])); + }); + + it('must stringify error objects, including extra properties', () => { + const obj = new Error('Wubba Lubba Dub Dub'); + // @ts-ignore + obj.reason = new TypeError("I'm pickle Riiick!"); + // @ts-ignore + obj.extra = 'some extra prop'; + + // Stack is inconsistent across browsers, so override it and just make sure its stringified + obj.stack = 'x'; + // @ts-ignore + obj.reason.stack = 'x'; + + // IE 10/11 + // @ts-ignore + delete obj.description; + // @ts-ignore + delete obj.reason.description; + + // Safari doesn't allow deleting those properties from error object, yet only it provides them + const result = serialize(obj) + .replace(/ +"(line|column|sourceURL)": .+,?\n/g, '') + .replace(/,\n( +)}/g, '\n$1}'); // make sure to strip trailing commas as well + + expect(result).toEqual( + jsonify({ + message: 'Wubba Lubba Dub Dub', + name: 'Error', + stack: 'x', + reason: { + message: "I'm pickle Riiick!", + name: 'TypeError', + stack: 'x', + }, + extra: 'some extra prop', + }), + ); + }); + }); + + it('must stringify error objects with circular references', () => { + const obj = new Error('Wubba Lubba Dub Dub'); + // @ts-ignore + obj.reason = obj; + + // Stack is inconsistent across browsers, so override it and just make sure its stringified + obj.stack = 'x'; + // @ts-ignore + obj.reason.stack = 'x'; + + // IE 10/11 + // @ts-ignore + delete obj.description; + + // Safari doesn't allow deleting those properties from error object, yet only it provides them + const result = serialize(obj) + .replace(/ +"(line|column|sourceURL)": .+,?\n/g, '') + .replace(/,\n( +)}/g, '\n$1}'); // make sure to strip trailing commas as well + + expect(result).toEqual( + jsonify({ + message: 'Wubba Lubba Dub Dub', + name: 'Error', + stack: 'x', + reason: '[Circular ~]', + }), + ); + }); }); describe('deserialize()', () => { diff --git a/packages/utils/test/tslint.json b/packages/utils/test/tslint.json index b6e790987903..3d047d490f0e 100644 --- a/packages/utils/test/tslint.json +++ b/packages/utils/test/tslint.json @@ -4,6 +4,8 @@ "completed-docs": false, "no-unused-expression": false, "no-implicit-dependencies": [true, "dev"], - "no-unsafe-any": false + "no-unsafe-any": false, + // We disable this rule, because order in `serialize()` tests matter + "object-literal-sort-keys": false } }