From 887021c8e13b677541dc803a35600196d21b96b4 Mon Sep 17 00:00:00 2001 From: david0xd Date: Fri, 24 Jun 2022 18:14:17 +0200 Subject: [PATCH 01/33] Add JSON storage limit utilities --- src/json.test.ts | 40 ++++++++++++++++++++ src/json.ts | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/json.test.ts b/src/json.test.ts index a8162707..54d0f784 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -4,10 +4,12 @@ import { assertIsJsonRpcRequest, assertIsJsonRpcSuccess, getJsonRpcIdValidator, + getJsonSerializableInfo, isJsonRpcFailure, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcSuccess, + isPlainObject, isValidJson, jsonrpc2, JsonRpcError, @@ -286,4 +288,42 @@ describe('json', () => { ).not.toThrow(); }); }); + + describe('isPlainObject', () => { + it('should return true for a plain object', () => { + const somePlainObject = { + someKey: 'someValue', + }; + + expect(isPlainObject(somePlainObject)).toBe(true); + }); + + it('should return false if function is passed', () => { + const someFunction = (someArg: string) => { + return someArg; + }; + + expect(isPlainObject(someFunction)).toBe(false); + }); + + it('should return false if Set object is passed', () => { + const someSet = new Set(); + someSet.add('something'); + + expect(isPlainObject(someSet)).toBe(false); + }); + }); + + describe('getJsonSerializableInfo', () => { + it('should return true for serialization and 14 for a size', () => { + const valueToSerialize = { + a: 'bc', + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 12, + ]); + }); + }); }); diff --git a/src/json.ts b/src/json.ts index 7ab35f9b..1a836a2e 100644 --- a/src/json.ts +++ b/src/json.ts @@ -266,3 +266,98 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { }; return isValidJsonRpcId; } + +const NULL_LENGTH = 4; // null +const COMMA_LENGTH = 1; // , +const WRAPPER_LENGTH = 1; // either [ ] { } +const TRUE_LENGTH = 4; // true +const FALSE_LENGTH = 5; // false +const ZERO_LENGTH = 1; // 0 +const QUOTE_LENGTH = 1; // " +const COLON_LENGTH = 1; // : + +/** + * Checks whether a value is JSON serializable and counts the total number + * of bytes needed to store the serialized version of the value. + * + * This function assumes the encoding of the JSON is done in UTF-8. + * + * @param value - A potential JSON serializable value. + * @returns A tuple containing a boolean that signals whether the value was serializable and + * a number of bytes that it will use when serialized to JSON. + */ +export function getJsonSerializableInfo(value: unknown): [boolean, number] { + if (value === undefined) { + return [true, 0]; + } else if (value === null) { + return [true, NULL_LENGTH]; + } + + // eslint-disable-next-line default-case + switch (typeof value) { + case 'string': + // TODO: Check if character is ASCII (1B) or UTF-8 (2B) + return [true, value.length * 2 + QUOTE_LENGTH * 2]; + case 'boolean': + return [true, value ? TRUE_LENGTH : FALSE_LENGTH]; + case 'number': + return [ + true, + // Check number of digits since all digits are 1 byte + value === 0 ? ZERO_LENGTH : Math.floor(Math.log10(value) + 1), + ]; + } + + if (!isPlainObject(value) && !Array.isArray(value)) { + return [false, 0]; + } + + try { + return [ + true, + Object.entries(value).reduce( + (sum, [key, nestedValue], idx, arr) => { + const [valid, size] = getJsonSerializableInfo(nestedValue); + if (!valid) { + throw new Error(); + } + // Objects will have be serialized with "key": value, therefore we include the key in the calculation here + const keySize = Array.isArray(value) + ? 0 + : key.length + COMMA_LENGTH + COLON_LENGTH * 2; + const separator = idx < arr.length - 1 ? COMMA_LENGTH : 0; + return sum + keySize + size + separator; + }, + // Starts at 2 because the string will minimally contain {}/[] + WRAPPER_LENGTH * 2, + ), + ]; + } catch (_) { + return [false, 0]; + } +} + +export type PlainObject = Record; + +/** + * Check if the value is plain object. + * + * @param value - Value to be checked. + * @returns Boolean. + */ +export function isPlainObject(value: unknown): value is PlainObject { + if (typeof value !== 'object' || value === null) { + return false; + } + + try { + let proto = value; + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + + return Object.getPrototypeOf(value) === proto; + } catch (_) { + return false; + } +} From 95da5272b54e71aea63bc4a7f79ebe2584c92211 Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 27 Jun 2022 12:47:53 +0200 Subject: [PATCH 02/33] Add handling for string length depending on encoding --- src/json.test.ts | 15 +++++++++++++-- src/json.ts | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 54d0f784..ed6b50f1 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -315,14 +315,25 @@ describe('json', () => { }); describe('getJsonSerializableInfo', () => { - it('should return true for serialization and 14 for a size', () => { + it('should return true for serialization and 10 for a size', () => { const valueToSerialize = { a: 'bc', }; expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ true, - 12, + 10, + ]); + }); + + it('should return true for serialization and 16 for a size when mixed UTF8 and ASCII values are used', () => { + const valueToSerialize = { + a: 'bcšečf', + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 16, ]); }); }); diff --git a/src/json.ts b/src/json.ts index 1a836a2e..10eed5d6 100644 --- a/src/json.ts +++ b/src/json.ts @@ -296,14 +296,14 @@ export function getJsonSerializableInfo(value: unknown): [boolean, number] { // eslint-disable-next-line default-case switch (typeof value) { case 'string': - // TODO: Check if character is ASCII (1B) or UTF-8 (2B) - return [true, value.length * 2 + QUOTE_LENGTH * 2]; + return [true, calculateStringSize(value) + QUOTE_LENGTH * 2]; case 'boolean': return [true, value ? TRUE_LENGTH : FALSE_LENGTH]; case 'number': return [ true, // Check number of digits since all digits are 1 byte + // TODO: This needs to be improved to handle negative and decimal numbers value === 0 ? ZERO_LENGTH : Math.floor(Math.log10(value) + 1), ]; } @@ -361,3 +361,33 @@ export function isPlainObject(value: unknown): value is PlainObject { return false; } } + +/** + * Check if character or string is ASCII. + * + * @param value - String or character. + * @returns Boolean, true if value is ASCII, false if not. + */ +export function isASCII(value: string) { + // eslint-disable-next-line no-control-regex,require-unicode-regexp + return /^[\x00-\x7F]*$/.test(value); +} + +/** + * Calculate string size. + * + * @param value - String value to calculate size. + * @returns Number of bytes used to store whole string value. + */ +export function calculateStringSize(value: string) { + let size = 0; + for (const character of value) { + if (isASCII(character)) { + size += 1; + } else { + size += 2; + } + } + + return size; +} From 5a38ae7a1d57427119f5b1a75844da5eabf0088d Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 27 Jun 2022 17:55:19 +0200 Subject: [PATCH 03/33] Add handling for number sizes --- src/json.test.ts | 30 +++++++++++++++++++ src/json.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index ed6b50f1..e1334a54 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -3,6 +3,7 @@ import { assertIsJsonRpcNotification, assertIsJsonRpcRequest, assertIsJsonRpcSuccess, + calculateNumberSize, getJsonRpcIdValidator, getJsonSerializableInfo, isJsonRpcFailure, @@ -314,6 +315,24 @@ describe('json', () => { }); }); + describe('calculateNumberSize', () => { + it('should return 3 for a "100" number size', () => { + expect(calculateNumberSize(100)).toBe(3); + }); + + it('should return 4 for a "-100" number size', () => { + expect(calculateNumberSize(-100)).toBe(4); + }); + + it('should return 4 for a "-0.3" number size', () => { + expect(calculateNumberSize(-0.3)).toBe(4); + }); + + it('should return 12 for a "0.0000000005" number size', () => { + expect(calculateNumberSize(0.0000000005)).toBe(12); + }); + }); + describe('getJsonSerializableInfo', () => { it('should return true for serialization and 10 for a size', () => { const valueToSerialize = { @@ -326,6 +345,17 @@ describe('json', () => { ]); }); + it('should return true for serialization and 11 for a size', () => { + const valueToSerialize = { + a: 1234, + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 10, + ]); + }); + it('should return true for serialization and 16 for a size when mixed UTF8 and ASCII values are used', () => { const valueToSerialize = { a: 'bcšečf', diff --git a/src/json.ts b/src/json.ts index 10eed5d6..1a12bac4 100644 --- a/src/json.ts +++ b/src/json.ts @@ -275,6 +275,7 @@ const FALSE_LENGTH = 5; // false const ZERO_LENGTH = 1; // 0 const QUOTE_LENGTH = 1; // " const COLON_LENGTH = 1; // : +const DOT_LENGTH = 1; // : /** * Checks whether a value is JSON serializable and counts the total number @@ -300,12 +301,7 @@ export function getJsonSerializableInfo(value: unknown): [boolean, number] { case 'boolean': return [true, value ? TRUE_LENGTH : FALSE_LENGTH]; case 'number': - return [ - true, - // Check number of digits since all digits are 1 byte - // TODO: This needs to be improved to handle negative and decimal numbers - value === 0 ? ZERO_LENGTH : Math.floor(Math.log10(value) + 1), - ]; + return [true, calculateNumberSize(value)]; } if (!isPlainObject(value) && !Array.isArray(value)) { @@ -391,3 +387,70 @@ export function calculateStringSize(value: string) { return size; } + +/** + * Calculate size of a number ofter JSON serialization. + * + * @param value - Number value to calculate size. + * @returns Number of bytes used to store whole number in JSON. + */ +export function calculateNumberSize(value: number): number { + if (value === 0) { + return ZERO_LENGTH; + } + + let size = 0; + // Work with absolute (positive) numbers only + let absNum = value; + if (value < 0) { + absNum = Math.abs(value); + // Initially, count on a sign of a number (-) + size += 1; + } + + // If absolute value of a number is greater than 1 and is a whole number, + // then perform the fastest operation to calculate its size + if (absNum - Math.floor(absNum) === 0) { + size += Math.floor(Math.log10(absNum) + 1); + return size; + } + + // Work with decimal numbers + // First calculate the size of the whole part of a number + if (absNum >= 1) { + size += Math.floor(Math.log10(absNum) + 1); + } else { + size += ZERO_LENGTH; + } + // Then add the dot '.' size + size += DOT_LENGTH; + // Then calculate the number of decimal places + size += getNumberOfDecimals(absNum); + + return size; +} + +/** + * Calculate the number of decimals for a given number. + * + * @param value - Decimal number. + * @returns Number of decimals. + */ +export function getNumberOfDecimals(value: number): number { + if (Math.floor(value) === value) { + return 0; + } + + const str = value.toString(); + + if (str.indexOf('e-') > -1) { + return parseInt(str.split('e-')[1], 10); + } + + if (str.indexOf('.') !== -1 && str.indexOf('-') !== -1) { + return str.split('-')[1].length; + } else if (str.indexOf('.') !== -1) { + return str.split('.')[1].length; + } + return str.split('-')[1].length; +} From b3d204b86714b2b095a5933856f6d94966d64fb0 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 28 Jun 2022 12:07:31 +0200 Subject: [PATCH 04/33] Refactor code and add more tests --- src/json.test.ts | 45 -------------- src/json.ts | 152 ++++------------------------------------------- src/misc.test.ts | 68 +++++++++++++++++++++ src/misc.ts | 140 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 184 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index e1334a54..b174e757 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -3,14 +3,12 @@ import { assertIsJsonRpcNotification, assertIsJsonRpcRequest, assertIsJsonRpcSuccess, - calculateNumberSize, getJsonRpcIdValidator, getJsonSerializableInfo, isJsonRpcFailure, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcSuccess, - isPlainObject, isValidJson, jsonrpc2, JsonRpcError, @@ -290,49 +288,6 @@ describe('json', () => { }); }); - describe('isPlainObject', () => { - it('should return true for a plain object', () => { - const somePlainObject = { - someKey: 'someValue', - }; - - expect(isPlainObject(somePlainObject)).toBe(true); - }); - - it('should return false if function is passed', () => { - const someFunction = (someArg: string) => { - return someArg; - }; - - expect(isPlainObject(someFunction)).toBe(false); - }); - - it('should return false if Set object is passed', () => { - const someSet = new Set(); - someSet.add('something'); - - expect(isPlainObject(someSet)).toBe(false); - }); - }); - - describe('calculateNumberSize', () => { - it('should return 3 for a "100" number size', () => { - expect(calculateNumberSize(100)).toBe(3); - }); - - it('should return 4 for a "-100" number size', () => { - expect(calculateNumberSize(-100)).toBe(4); - }); - - it('should return 4 for a "-0.3" number size', () => { - expect(calculateNumberSize(-0.3)).toBe(4); - }); - - it('should return 12 for a "0.0000000005" number size', () => { - expect(calculateNumberSize(0.0000000005)).toBe(12); - }); - }); - describe('getJsonSerializableInfo', () => { it('should return true for serialization and 10 for a size', () => { const valueToSerialize = { diff --git a/src/json.ts b/src/json.ts index 1a12bac4..06ec5a26 100644 --- a/src/json.ts +++ b/src/json.ts @@ -1,5 +1,11 @@ import deepEqual from 'fast-deep-equal'; -import { hasProperty } from './misc'; +import { + calculateNumberSize, + calculateStringSize, + hasProperty, + isPlainObject, + JsonSize, +} from './misc'; /** * Any JSON-compatible value. @@ -267,16 +273,6 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { return isValidJsonRpcId; } -const NULL_LENGTH = 4; // null -const COMMA_LENGTH = 1; // , -const WRAPPER_LENGTH = 1; // either [ ] { } -const TRUE_LENGTH = 4; // true -const FALSE_LENGTH = 5; // false -const ZERO_LENGTH = 1; // 0 -const QUOTE_LENGTH = 1; // " -const COLON_LENGTH = 1; // : -const DOT_LENGTH = 1; // : - /** * Checks whether a value is JSON serializable and counts the total number * of bytes needed to store the serialized version of the value. @@ -291,15 +287,15 @@ export function getJsonSerializableInfo(value: unknown): [boolean, number] { if (value === undefined) { return [true, 0]; } else if (value === null) { - return [true, NULL_LENGTH]; + return [true, JsonSize.NULL]; } // eslint-disable-next-line default-case switch (typeof value) { case 'string': - return [true, calculateStringSize(value) + QUOTE_LENGTH * 2]; + return [true, calculateStringSize(value) + JsonSize.QUOTE * 2]; case 'boolean': - return [true, value ? TRUE_LENGTH : FALSE_LENGTH]; + return [true, value ? JsonSize.TRUE : JsonSize.FALSE]; case 'number': return [true, calculateNumberSize(value)]; } @@ -320,137 +316,15 @@ export function getJsonSerializableInfo(value: unknown): [boolean, number] { // Objects will have be serialized with "key": value, therefore we include the key in the calculation here const keySize = Array.isArray(value) ? 0 - : key.length + COMMA_LENGTH + COLON_LENGTH * 2; - const separator = idx < arr.length - 1 ? COMMA_LENGTH : 0; + : key.length + JsonSize.COMMA + JsonSize.COLON * 2; + const separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; return sum + keySize + size + separator; }, // Starts at 2 because the string will minimally contain {}/[] - WRAPPER_LENGTH * 2, + JsonSize.WRAPPER * 2, ), ]; } catch (_) { return [false, 0]; } } - -export type PlainObject = Record; - -/** - * Check if the value is plain object. - * - * @param value - Value to be checked. - * @returns Boolean. - */ -export function isPlainObject(value: unknown): value is PlainObject { - if (typeof value !== 'object' || value === null) { - return false; - } - - try { - let proto = value; - while (Object.getPrototypeOf(proto) !== null) { - proto = Object.getPrototypeOf(proto); - } - - return Object.getPrototypeOf(value) === proto; - } catch (_) { - return false; - } -} - -/** - * Check if character or string is ASCII. - * - * @param value - String or character. - * @returns Boolean, true if value is ASCII, false if not. - */ -export function isASCII(value: string) { - // eslint-disable-next-line no-control-regex,require-unicode-regexp - return /^[\x00-\x7F]*$/.test(value); -} - -/** - * Calculate string size. - * - * @param value - String value to calculate size. - * @returns Number of bytes used to store whole string value. - */ -export function calculateStringSize(value: string) { - let size = 0; - for (const character of value) { - if (isASCII(character)) { - size += 1; - } else { - size += 2; - } - } - - return size; -} - -/** - * Calculate size of a number ofter JSON serialization. - * - * @param value - Number value to calculate size. - * @returns Number of bytes used to store whole number in JSON. - */ -export function calculateNumberSize(value: number): number { - if (value === 0) { - return ZERO_LENGTH; - } - - let size = 0; - // Work with absolute (positive) numbers only - let absNum = value; - if (value < 0) { - absNum = Math.abs(value); - // Initially, count on a sign of a number (-) - size += 1; - } - - // If absolute value of a number is greater than 1 and is a whole number, - // then perform the fastest operation to calculate its size - if (absNum - Math.floor(absNum) === 0) { - size += Math.floor(Math.log10(absNum) + 1); - return size; - } - - // Work with decimal numbers - // First calculate the size of the whole part of a number - if (absNum >= 1) { - size += Math.floor(Math.log10(absNum) + 1); - } else { - size += ZERO_LENGTH; - } - // Then add the dot '.' size - size += DOT_LENGTH; - // Then calculate the number of decimal places - size += getNumberOfDecimals(absNum); - - return size; -} - -/** - * Calculate the number of decimals for a given number. - * - * @param value - Decimal number. - * @returns Number of decimals. - */ -export function getNumberOfDecimals(value: number): number { - if (Math.floor(value) === value) { - return 0; - } - - const str = value.toString(); - - if (str.indexOf('e-') > -1) { - return parseInt(str.split('e-')[1], 10); - } - - if (str.indexOf('.') !== -1 && str.indexOf('-') !== -1) { - return str.split('-')[1].length; - } else if (str.indexOf('.') !== -1) { - return str.split('.')[1].length; - } - return str.split('-')[1].length; -} diff --git a/src/misc.test.ts b/src/misc.test.ts index a380ef80..88e6f9e7 100644 --- a/src/misc.test.ts +++ b/src/misc.test.ts @@ -4,6 +4,9 @@ import { isObject, hasProperty, RuntimeObject, + isPlainObject, + calculateNumberSize, + isASCII, } from '.'; describe('miscellaneous', () => { @@ -108,4 +111,69 @@ describe('miscellaneous', () => { }); }); }); + + describe('isPlainObject', () => { + it('should return true for a plain object', () => { + const somePlainObject = { + someKey: 'someValue', + }; + + expect(isPlainObject(somePlainObject)).toBe(true); + }); + + it('should return false if function is passed', () => { + const someFunction = (someArg: string) => { + return someArg; + }; + + expect(isPlainObject(someFunction)).toBe(false); + }); + + it('should return false if Set object is passed', () => { + const someSet = new Set(); + someSet.add('something'); + + expect(isPlainObject(someSet)).toBe(false); + }); + }); + + describe('isASCII', () => { + it('should return true for "A" which is the ASCII character', () => { + expect(isASCII('A')).toBe(true); + }); + + it('should return false for "Š" which is not the ASCII character', () => { + expect(isASCII('Š')).toBe(false); + }); + }); + + describe('calculateNumberSize', () => { + it('should return 3 for a "100" number size', () => { + expect(calculateNumberSize(100)).toBe(3); + }); + + it('should return 4 for a "-100" number size', () => { + expect(calculateNumberSize(-100)).toBe(4); + }); + + it('should return 4 for a "-0.3" number size', () => { + expect(calculateNumberSize(-0.3)).toBe(4); + }); + + it('should return 7 for a "-123.45" number size', () => { + expect(calculateNumberSize(-123.45)).toBe(7); + }); + + it('should return 12 for a "0.0000000005" number size', () => { + expect(calculateNumberSize(0.0000000005)).toBe(12); + }); + + it('should return 16 for a "9007199254740991" number size', () => { + expect(calculateNumberSize(9007199254740991)).toBe(16); + }); + + it('should return 17 for a "-9007199254740991" number size', () => { + expect(calculateNumberSize(-9007199254740991)).toBe(17); + }); + }); }); diff --git a/src/misc.ts b/src/misc.ts index a1470c16..ba3dc0a9 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -92,3 +92,143 @@ export const hasProperty = ( object: RuntimeObject, name: string | number | symbol, ): boolean => Object.hasOwnProperty.call(object, name); + +export type PlainObject = Record; + +/** + * Predefined sizes (in Bytes) of specific parts of JSON structure. + */ +export enum JsonSize { + NULL = 4, + COMMA = 1, + WRAPPER = 1, + TRUE = 4, + FALSE = 5, + ZERO = 1, + QUOTE = 1, + COLON = 1, + DOT = 1, +} + +/** + * Check if the value is plain object. + * + * @param value - Value to be checked. + * @returns Boolean. + */ +export function isPlainObject(value: unknown): value is PlainObject { + if (typeof value !== 'object' || value === null) { + return false; + } + + try { + let proto = value; + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + + return Object.getPrototypeOf(value) === proto; + } catch (_) { + return false; + } +} + +/** + * Check if character or string is ASCII. + * + * @param value - String or character. + * @returns Boolean, true if value is ASCII, false if not. + */ +export function isASCII(value: string) { + // eslint-disable-next-line no-control-regex,require-unicode-regexp + return /^[\x00-\x7F]*$/.test(value); +} + +/** + * Calculate string size. + * + * @param value - String value to calculate size. + * @returns Number of bytes used to store whole string value. + */ +export function calculateStringSize(value: string) { + let size = 0; + for (const character of value) { + if (isASCII(character)) { + size += 1; + } else { + size += 2; + } + } + + return size; +} + +/** + * Calculate size of a number ofter JSON serialization. + * + * @param value - Number value to calculate size. + * @returns Number of bytes used to store whole number in JSON. + */ +export function calculateNumberSize(value: number): number { + if (value === 0) { + return JsonSize.ZERO; + } + + let size = 0; + // Work with absolute (positive) numbers only + let absNum = value; + if (value < 0) { + absNum = Math.abs(value); + // Initially, count on a sign of a number (-) + size += 1; + } + + // If absolute value of a number is greater than 1 and is a whole number, + // then perform the fastest operation to calculate its size + // Portion of numbers passed to this function will fall into this category, + // so it will not be required to do to string conversion and manipulation. + if (absNum - Math.floor(absNum) === 0) { + size += Math.floor(Math.log10(absNum) + 1); + return size; + } + + // Work with decimal numbers + // First calculate the size of the whole part of a number + if (absNum >= 1) { + size += Math.floor(Math.log10(absNum) + 1); + } else { + // Because absolute value is less than 1, count size of a zero digit + size += JsonSize.ZERO; + } + // Then add the dot '.' size + size += JsonSize.DOT; + // Then calculate the number of decimal places + size += getNumberOfDecimals(absNum); + + return size; +} + +/** + * Calculate the number of decimals for a given number. + * + * @param value - Decimal number. + * @returns Number of decimals. + */ +export function getNumberOfDecimals(value: number): number { + if (Math.floor(value) === value) { + return 0; + } + + const str = value.toString(); + + if (str.indexOf('e-') > -1) { + return parseInt(str.split('e-')[1], 10); + } + + if (str.indexOf('.') !== -1 && str.indexOf('-') !== -1) { + return str.split('-')[1].length; + } else if (str.indexOf('.') !== -1) { + return str.split('.')[1].length; + } + return str.split('-')[1].length; +} From f4f38e4f8371bae4a88589e7b279008938cbc457 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 28 Jun 2022 14:58:24 +0200 Subject: [PATCH 05/33] Add functional code improvements and tests --- src/misc.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ src/misc.ts | 9 ++----- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/misc.test.ts b/src/misc.test.ts index 88e6f9e7..660748e1 100644 --- a/src/misc.test.ts +++ b/src/misc.test.ts @@ -7,6 +7,8 @@ import { isPlainObject, calculateNumberSize, isASCII, + calculateStringSize, + getNumberOfDecimals, } from '.'; describe('miscellaneous', () => { @@ -135,6 +137,15 @@ describe('miscellaneous', () => { expect(isPlainObject(someSet)).toBe(false); }); + + it('should return false if an exception is thrown', () => { + const someObject = { something: 'anything' }; + jest.spyOn(Object, 'getPrototypeOf').mockImplementationOnce(() => { + throw new Error(); + }); + + expect(isPlainObject(someObject)).toBe(false); + }); }); describe('isASCII', () => { @@ -147,6 +158,26 @@ describe('miscellaneous', () => { }); }); + describe('calculateStringSize', () => { + it('should return 94 for a size of ASCII string', () => { + const str = + '!"#$%&\'()*+,-./0123456789:;<=>?' + + '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]' + + '^_`abcdefghijklmnopqrstuvwxyz{|}~'; + expect(calculateStringSize(str)).toBe(94); + }); + + it('should return 10 for a size of UTF8 string', () => { + const str = 'šđćčž'; + expect(calculateStringSize(str)).toBe(10); + }); + + it('should return 15 for a size of mixed, ASCII and UTF8 string', () => { + const str = 'ašbđcćdčež'; + expect(calculateStringSize(str)).toBe(15); + }); + }); + describe('calculateNumberSize', () => { it('should return 3 for a "100" number size', () => { expect(calculateNumberSize(100)).toBe(3); @@ -175,5 +206,43 @@ describe('miscellaneous', () => { it('should return 17 for a "-9007199254740991" number size', () => { expect(calculateNumberSize(-9007199254740991)).toBe(17); }); + + it('should return 1 for a "0" number size', () => { + expect(calculateNumberSize(0)).toBe(1); + }); + }); + + describe('getNumberOfDecimals', () => { + it('should return 0 for number of decimals of "100"', () => { + expect(getNumberOfDecimals(100)).toBe(0); + }); + + it('should return 3 for number of decimals of "0.333"', () => { + expect(getNumberOfDecimals(0.333)).toBe(3); + }); + + it('should return 5 for number of decimals of "-100.88888"', () => { + expect(getNumberOfDecimals(-100.88888)).toBe(5); + }); + + it('should return 8 for number of decimals of "100000.00000008"', () => { + expect(getNumberOfDecimals(100000.00000008)).toBe(8); + }); + + it('should return 11 for number of decimals of "-0.00000000009"', () => { + expect(getNumberOfDecimals(-0.00000000009)).toBe(11); + }); + + it('should return 12 for number of decimals of "0.000000000001"', () => { + expect(getNumberOfDecimals(0.000000000001)).toBe(12); + }); + + it('should return 6 for number of decimals of "5e-6"', () => { + expect(getNumberOfDecimals(5e-6)).toBe(6); + }); + + it('should return 6 for number of decimals of "-5e-11"', () => { + expect(getNumberOfDecimals(-5e-11)).toBe(11); + }); }); }); diff --git a/src/misc.ts b/src/misc.ts index ba3dc0a9..034c3a47 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -219,16 +219,11 @@ export function getNumberOfDecimals(value: number): number { return 0; } - const str = value.toString(); + const str = Math.abs(value).toString(); if (str.indexOf('e-') > -1) { return parseInt(str.split('e-')[1], 10); } - if (str.indexOf('.') !== -1 && str.indexOf('-') !== -1) { - return str.split('-')[1].length; - } else if (str.indexOf('.') !== -1) { - return str.split('.')[1].length; - } - return str.split('-')[1].length; + return str.split('.')[1].length; } From a354cf963d8f0892c3fc0c0aa5d67f3b72f8d6a8 Mon Sep 17 00:00:00 2001 From: david0xd Date: Wed, 29 Jun 2022 19:14:45 +0200 Subject: [PATCH 06/33] Add edge case handling and more complex tests --- src/json.test.ts | 130 +++++++++++++++++++++++++++++++++++++++++++++++ src/json.ts | 24 +++++++-- src/misc.test.ts | 4 +- src/misc.ts | 4 ++ 4 files changed, 157 insertions(+), 5 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index b174e757..c162142d 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -321,5 +321,135 @@ describe('json', () => { 16, ]); }); + + it('should return true for serialization and 2 for a size when only one key with undefined value is provided', () => { + const valueToSerialize = { + a: undefined, + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 2, + ]); + }); + + it('should return true for serialization and 25 for a size when some of the values are undefined', () => { + const valueToSerialize = { + a: undefined, + b: 'b', + c: undefined, + d: 'd', + e: undefined, + f: 'f', + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 25, + ]); + }); + + it('should return true for serialization and 17 for a size with mixed null and undefined in an array', () => { + const valueToSerialize = [ + null, + undefined, + null, + undefined, + undefined, + undefined, + null, + null, + null, + undefined, + ]; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 51, + ]); + }); + + it('should return true for serialization and 1022 for a size of a complex nested object', () => { + const complexObject = { + data: { + account: { + __typename: 'Account', + registrations: [ + { + __typename: 'Registration', + domain: { + __typename: 'Domain', + isMigrated: true, + labelName: 'mycrypto', + labelhash: + '0x9a781ca0d227debc3ee76d547c960b0803a6c9f58c6d3b4722f12ede7e6ef7c9', + name: 'mycrypto.eth', + parent: { __typename: 'Domain', name: 'eth' }, + }, + expiryDate: '1754111315', + }, + ], + }, + moreComplexity: { + // numbers: [ + // -5e-11, + // 5e-9, + // 0.000000000001, + // -0.00000000009, + // 100000.00000008, + // -100.88888, + // 0.333, + // 1000000000000, + // ], + moreNestedObjects: { + nestedAgain: { + nestedAgain: { + andAgain: { + andAgain: { + value: true, + again: { + value: false, + }, + }, + }, + }, + }, + }, + differentEncodings: { + ascii: + '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~', + utf8: 'šđćčžЀЂЇЄЖФћΣΩδλ', + mixed: 'ABCDEFGHIJ KLMNOPQRST UVWXYZšđćč žЀЂЇЄЖФћΣΩδλ', + }, + specialObjectsTypesAndValues: { + t: [true, true, true], + f: [false, false, false], + nulls: [null, null, null], + undef: undefined, + mixed: [ + null, + undefined, + null, + undefined, + null, + true, + null, + false, + null, + undefined, + ], + inObject: { + valueOne: null, + valueTwo: undefined, + t: true, + f: false, + }, + }, + }, + }, + }; + + expect(getJsonSerializableInfo(complexObject)).toStrictEqual([true, 934]); + }); }); }); diff --git a/src/json.ts b/src/json.ts index 06ec5a26..1a88e4bb 100644 --- a/src/json.ts +++ b/src/json.ts @@ -285,11 +285,14 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { */ export function getJsonSerializableInfo(value: unknown): [boolean, number] { if (value === undefined) { + // Return zero for undefined, since these are omitted from JSON serialization return [true, 0]; } else if (value === null) { + // Return already specified constant size for null (special object) return [true, JsonSize.NULL]; } + // Check and calculate sizes for basic types // eslint-disable-next-line default-case switch (typeof value) { case 'string': @@ -300,27 +303,42 @@ export function getJsonSerializableInfo(value: unknown): [boolean, number] { return [true, calculateNumberSize(value)]; } + // If object is not plain and cannot be serialized properly, + // stop here and return false for serialization if (!isPlainObject(value) && !Array.isArray(value)) { return [false, 0]; } + // Continue object decomposition try { return [ true, Object.entries(value).reduce( (sum, [key, nestedValue], idx, arr) => { - const [valid, size] = getJsonSerializableInfo(nestedValue); + // Recursively process next nested object or primitive type + // eslint-disable-next-line prefer-const + let [valid, size] = getJsonSerializableInfo(nestedValue); if (!valid) { throw new Error(); } + + // If the size is 0, the value is undefined and undefined in an array + // when serialized will be replaced with null + if (size === 0 && Array.isArray(value)) { + size = JsonSize.NULL; + } + // Objects will have be serialized with "key": value, therefore we include the key in the calculation here const keySize = Array.isArray(value) ? 0 : key.length + JsonSize.COMMA + JsonSize.COLON * 2; const separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; - return sum + keySize + size + separator; + // If the size is 0, that means the object is undefined and + // the rest of the object structure will be omitted + return size === 0 ? sum : sum + keySize + size + separator; }, - // Starts at 2 because the string will minimally contain {}/[] + // Starts at 2 because the serialized JSON string data (plain text) + // will minimally contain {}/[] JsonSize.WRAPPER * 2, ), ]; diff --git a/src/misc.test.ts b/src/misc.test.ts index 660748e1..24c9a017 100644 --- a/src/misc.test.ts +++ b/src/misc.test.ts @@ -159,12 +159,12 @@ describe('miscellaneous', () => { }); describe('calculateStringSize', () => { - it('should return 94 for a size of ASCII string', () => { + it('should return 96 for a size of ASCII string', () => { const str = '!"#$%&\'()*+,-./0123456789:;<=>?' + '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]' + '^_`abcdefghijklmnopqrstuvwxyz{|}~'; - expect(calculateStringSize(str)).toBe(94); + expect(calculateStringSize(str)).toBe(96); }); it('should return 10 for a size of UTF8 string', () => { diff --git a/src/misc.ts b/src/misc.ts index 034c3a47..41a4508a 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -160,6 +160,10 @@ export function calculateStringSize(value: string) { } } + // Detect characters that need backslash escape + const re = /\\|'/gu; + size += ((value || '').match(re) || []).length; + return size; } From 665554551df01a0aaa3dfd88539a68c35c0c26e8 Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 30 Jun 2022 14:02:04 +0200 Subject: [PATCH 07/33] Refactor code, change approaches and improve tests --- src/json.test.ts | 45 +++++++++++++++++++++++++++---------- src/misc.test.ts | 41 +++++----------------------------- src/misc.ts | 58 +++--------------------------------------------- 3 files changed, 42 insertions(+), 102 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index c162142d..d777cf38 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -369,7 +369,25 @@ describe('json', () => { ]); }); - it('should return true for serialization and 1022 for a size of a complex nested object', () => { + it('should return true for serialization and 73 for a size, for an array of numbers', () => { + const valueToSerialize = [ + -5e-11, + 5e-9, + 0.000000000001, + -0.00000000009, + 100000.00000008, + -100.88888, + 0.333, + 1000000000000, + ]; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + true, + 73, + ]); + }); + + it('should return true for serialization and 1018 for a size of a complex nested object', () => { const complexObject = { data: { account: { @@ -391,16 +409,16 @@ describe('json', () => { ], }, moreComplexity: { - // numbers: [ - // -5e-11, - // 5e-9, - // 0.000000000001, - // -0.00000000009, - // 100000.00000008, - // -100.88888, - // 0.333, - // 1000000000000, - // ], + numbers: [ + -5e-11, + 5e-9, + 0.000000000001, + -0.00000000009, + 100000.00000008, + -100.88888, + 0.333, + 1000000000000, + ], moreNestedObjects: { nestedAgain: { nestedAgain: { @@ -449,7 +467,10 @@ describe('json', () => { }, }; - expect(getJsonSerializableInfo(complexObject)).toStrictEqual([true, 934]); + expect(getJsonSerializableInfo(complexObject)).toStrictEqual([ + true, + 1018, + ]); }); }); }); diff --git a/src/misc.test.ts b/src/misc.test.ts index 24c9a017..ebfc7385 100644 --- a/src/misc.test.ts +++ b/src/misc.test.ts @@ -8,7 +8,6 @@ import { calculateNumberSize, isASCII, calculateStringSize, - getNumberOfDecimals, } from '.'; describe('miscellaneous', () => { @@ -195,8 +194,10 @@ describe('miscellaneous', () => { expect(calculateNumberSize(-123.45)).toBe(7); }); - it('should return 12 for a "0.0000000005" number size', () => { - expect(calculateNumberSize(0.0000000005)).toBe(12); + it('should return 5 for a "0.0000000005" number size', () => { + // Because the number provided here will be changed to exponential notation + // 5e-10 by default + expect(calculateNumberSize(0.0000000005)).toBe(5); }); it('should return 16 for a "9007199254740991" number size', () => { @@ -210,39 +211,9 @@ describe('miscellaneous', () => { it('should return 1 for a "0" number size', () => { expect(calculateNumberSize(0)).toBe(1); }); - }); - - describe('getNumberOfDecimals', () => { - it('should return 0 for number of decimals of "100"', () => { - expect(getNumberOfDecimals(100)).toBe(0); - }); - - it('should return 3 for number of decimals of "0.333"', () => { - expect(getNumberOfDecimals(0.333)).toBe(3); - }); - - it('should return 5 for number of decimals of "-100.88888"', () => { - expect(getNumberOfDecimals(-100.88888)).toBe(5); - }); - - it('should return 8 for number of decimals of "100000.00000008"', () => { - expect(getNumberOfDecimals(100000.00000008)).toBe(8); - }); - - it('should return 11 for number of decimals of "-0.00000000009"', () => { - expect(getNumberOfDecimals(-0.00000000009)).toBe(11); - }); - - it('should return 12 for number of decimals of "0.000000000001"', () => { - expect(getNumberOfDecimals(0.000000000001)).toBe(12); - }); - - it('should return 6 for number of decimals of "5e-6"', () => { - expect(getNumberOfDecimals(5e-6)).toBe(6); - }); - it('should return 6 for number of decimals of "-5e-11"', () => { - expect(getNumberOfDecimals(-5e-11)).toBe(11); + it('should return 15 for a "100000.00000008" number size', () => { + expect(calculateNumberSize(100000.00000008)).toBe(15); }); }); }); diff --git a/src/misc.ts b/src/misc.ts index 41a4508a..cc8aaa26 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -174,60 +174,8 @@ export function calculateStringSize(value: string) { * @returns Number of bytes used to store whole number in JSON. */ export function calculateNumberSize(value: number): number { - if (value === 0) { - return JsonSize.ZERO; + if (value?.toString().length) { + return value.toString().length; } - - let size = 0; - // Work with absolute (positive) numbers only - let absNum = value; - if (value < 0) { - absNum = Math.abs(value); - // Initially, count on a sign of a number (-) - size += 1; - } - - // If absolute value of a number is greater than 1 and is a whole number, - // then perform the fastest operation to calculate its size - // Portion of numbers passed to this function will fall into this category, - // so it will not be required to do to string conversion and manipulation. - if (absNum - Math.floor(absNum) === 0) { - size += Math.floor(Math.log10(absNum) + 1); - return size; - } - - // Work with decimal numbers - // First calculate the size of the whole part of a number - if (absNum >= 1) { - size += Math.floor(Math.log10(absNum) + 1); - } else { - // Because absolute value is less than 1, count size of a zero digit - size += JsonSize.ZERO; - } - // Then add the dot '.' size - size += JsonSize.DOT; - // Then calculate the number of decimal places - size += getNumberOfDecimals(absNum); - - return size; -} - -/** - * Calculate the number of decimals for a given number. - * - * @param value - Decimal number. - * @returns Number of decimals. - */ -export function getNumberOfDecimals(value: number): number { - if (Math.floor(value) === value) { - return 0; - } - - const str = Math.abs(value).toString(); - - if (str.indexOf('e-') > -1) { - return parseInt(str.split('e-')[1], 10); - } - - return str.split('.')[1].length; + return 0; } From 80fdac74cac12637a2f5feb4d752888da10d78e2 Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 30 Jun 2022 14:57:28 +0200 Subject: [PATCH 08/33] Minor code refactoring and additional test --- src/json.test.ts | 11 +++++++++++ src/misc.ts | 7 ++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index d777cf38..72f70238 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -472,5 +472,16 @@ describe('json', () => { 1018, ]); }); + + it('should return false for serialization and 0 for size when non-serializable object was provided', () => { + const valueToSerialize = { + value: new Set(), + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + false, + 0, + ]); + }); }); }); diff --git a/src/misc.ts b/src/misc.ts index cc8aaa26..57a5274b 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -162,7 +162,7 @@ export function calculateStringSize(value: string) { // Detect characters that need backslash escape const re = /\\|'/gu; - size += ((value || '').match(re) || []).length; + size += (value.match(re) || []).length; return size; } @@ -174,8 +174,5 @@ export function calculateStringSize(value: string) { * @returns Number of bytes used to store whole number in JSON. */ export function calculateNumberSize(value: number): number { - if (value?.toString().length) { - return value.toString().length; - } - return 0; + return value.toString().length; } From 0634e04a46fd291758790adba8be8778ed4212ad Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 30 Jun 2022 16:04:58 +0200 Subject: [PATCH 09/33] Add one more unit test --- src/json.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/json.test.ts b/src/json.test.ts index 72f70238..395b5021 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -483,5 +483,24 @@ describe('json', () => { 0, ]); }); + + it('should return false for serialization and 0 for size when non-serializable nested object was provided', () => { + const valueToSerialize = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: new Set(), + }, + }, + }, + }, + }; + + expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + false, + 0, + ]); + }); }); }); From 0b5444ffb024722ad16d431e7f4eb4c2fbf63258 Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 30 Jun 2022 16:21:10 +0200 Subject: [PATCH 10/33] Refactor tests and test data --- src/json.test.ts | 159 ++++++----------------------------------------- src/test.data.ts | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 140 deletions(-) create mode 100644 src/test.data.ts diff --git a/src/json.test.ts b/src/json.test.ts index 395b5021..a621de91 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -1,3 +1,10 @@ +import { + arrayOfDifferentKindsOfNumbers, + arrayOfMixedSpecialObjects, + complexObject, + nonSerializableNestedObject, + objectMixedWithUndefinedValues, +} from './test.data'; import { assertIsJsonRpcFailure, assertIsJsonRpcNotification, @@ -334,139 +341,24 @@ describe('json', () => { }); it('should return true for serialization and 25 for a size when some of the values are undefined', () => { - const valueToSerialize = { - a: undefined, - b: 'b', - c: undefined, - d: 'd', - e: undefined, - f: 'f', - }; - - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ - true, - 25, - ]); + expect( + getJsonSerializableInfo(objectMixedWithUndefinedValues), + ).toStrictEqual([true, 25]); }); it('should return true for serialization and 17 for a size with mixed null and undefined in an array', () => { - const valueToSerialize = [ - null, - undefined, - null, - undefined, - undefined, - undefined, - null, - null, - null, - undefined, - ]; - - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ - true, - 51, - ]); + expect( + getJsonSerializableInfo(arrayOfMixedSpecialObjects), + ).toStrictEqual([true, 51]); }); it('should return true for serialization and 73 for a size, for an array of numbers', () => { - const valueToSerialize = [ - -5e-11, - 5e-9, - 0.000000000001, - -0.00000000009, - 100000.00000008, - -100.88888, - 0.333, - 1000000000000, - ]; - - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ - true, - 73, - ]); + expect( + getJsonSerializableInfo(arrayOfDifferentKindsOfNumbers), + ).toStrictEqual([true, 73]); }); it('should return true for serialization and 1018 for a size of a complex nested object', () => { - const complexObject = { - data: { - account: { - __typename: 'Account', - registrations: [ - { - __typename: 'Registration', - domain: { - __typename: 'Domain', - isMigrated: true, - labelName: 'mycrypto', - labelhash: - '0x9a781ca0d227debc3ee76d547c960b0803a6c9f58c6d3b4722f12ede7e6ef7c9', - name: 'mycrypto.eth', - parent: { __typename: 'Domain', name: 'eth' }, - }, - expiryDate: '1754111315', - }, - ], - }, - moreComplexity: { - numbers: [ - -5e-11, - 5e-9, - 0.000000000001, - -0.00000000009, - 100000.00000008, - -100.88888, - 0.333, - 1000000000000, - ], - moreNestedObjects: { - nestedAgain: { - nestedAgain: { - andAgain: { - andAgain: { - value: true, - again: { - value: false, - }, - }, - }, - }, - }, - }, - differentEncodings: { - ascii: - '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~', - utf8: 'šđćčžЀЂЇЄЖФћΣΩδλ', - mixed: 'ABCDEFGHIJ KLMNOPQRST UVWXYZšđćč žЀЂЇЄЖФћΣΩδλ', - }, - specialObjectsTypesAndValues: { - t: [true, true, true], - f: [false, false, false], - nulls: [null, null, null], - undef: undefined, - mixed: [ - null, - undefined, - null, - undefined, - null, - true, - null, - false, - null, - undefined, - ], - inObject: { - valueOne: null, - valueTwo: undefined, - t: true, - f: false, - }, - }, - }, - }, - }; - expect(getJsonSerializableInfo(complexObject)).toStrictEqual([ true, 1018, @@ -485,22 +377,9 @@ describe('json', () => { }); it('should return false for serialization and 0 for size when non-serializable nested object was provided', () => { - const valueToSerialize = { - levelOne: { - levelTwo: { - levelThree: { - levelFour: { - levelFive: new Set(), - }, - }, - }, - }, - }; - - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ - false, - 0, - ]); + expect( + getJsonSerializableInfo(nonSerializableNestedObject), + ).toStrictEqual([false, 0]); }); }); }); diff --git a/src/test.data.ts b/src/test.data.ts new file mode 100644 index 00000000..59ded2ce --- /dev/null +++ b/src/test.data.ts @@ -0,0 +1,123 @@ +export const complexObject = { + data: { + account: { + __typename: 'Account', + registrations: [ + { + __typename: 'Registration', + domain: { + __typename: 'Domain', + isMigrated: true, + labelName: 'mycrypto', + labelhash: + '0x9a781ca0d227debc3ee76d547c960b0803a6c9f58c6d3b4722f12ede7e6ef7c9', + name: 'mycrypto.eth', + parent: { __typename: 'Domain', name: 'eth' }, + }, + expiryDate: '1754111315', + }, + ], + }, + moreComplexity: { + numbers: [ + -5e-11, + 5e-9, + 0.000000000001, + -0.00000000009, + 100000.00000008, + -100.88888, + 0.333, + 1000000000000, + ], + moreNestedObjects: { + nestedAgain: { + nestedAgain: { + andAgain: { + andAgain: { + value: true, + again: { + value: false, + }, + }, + }, + }, + }, + }, + differentEncodings: { + ascii: + '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~', + utf8: 'šđćčžЀЂЇЄЖФћΣΩδλ', + mixed: 'ABCDEFGHIJ KLMNOPQRST UVWXYZšđćč žЀЂЇЄЖФћΣΩδλ', + }, + specialObjectsTypesAndValues: { + t: [true, true, true], + f: [false, false, false], + nulls: [null, null, null], + undef: undefined, + mixed: [ + null, + undefined, + null, + undefined, + null, + true, + null, + false, + null, + undefined, + ], + inObject: { + valueOne: null, + valueTwo: undefined, + t: true, + f: false, + }, + }, + }, + }, +}; + +export const nonSerializableNestedObject = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: new Set(), + }, + }, + }, + }, +}; + +export const arrayOfDifferentKindsOfNumbers = [ + -5e-11, + 5e-9, + 0.000000000001, + -0.00000000009, + 100000.00000008, + -100.88888, + 0.333, + 1000000000000, +]; + +export const arrayOfMixedSpecialObjects = [ + null, + undefined, + null, + undefined, + undefined, + undefined, + null, + null, + null, + undefined, +]; + +export const objectMixedWithUndefinedValues = { + a: undefined, + b: 'b', + c: undefined, + d: 'd', + e: undefined, + f: 'f', +}; From e884151bd111b969ca3c7aff7d7e2747caed26fa Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 30 Jun 2022 16:45:36 +0200 Subject: [PATCH 11/33] Add skip feature for JSON size calculation --- src/json.test.ts | 13 +++++++++++++ src/json.ts | 43 +++++++++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index a621de91..4186a106 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -381,5 +381,18 @@ describe('json', () => { getJsonSerializableInfo(nonSerializableNestedObject), ).toStrictEqual([false, 0]); }); + + it('should return true for serialization and 0 for a size when sizing is skipped', () => { + expect(getJsonSerializableInfo(complexObject, true)).toStrictEqual([ + true, + 0, + ]); + }); + + it('should return false for serialization and 0 for a size when sizing is skipped and non-serializable object was provided', () => { + expect( + getJsonSerializableInfo(nonSerializableNestedObject, true), + ).toStrictEqual([false, 0]); + }); }); }); diff --git a/src/json.ts b/src/json.ts index 1a88e4bb..62d3480d 100644 --- a/src/json.ts +++ b/src/json.ts @@ -280,27 +280,37 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { * This function assumes the encoding of the JSON is done in UTF-8. * * @param value - A potential JSON serializable value. + * @param skipSizing - Skip JSON size calculation (default: false). * @returns A tuple containing a boolean that signals whether the value was serializable and * a number of bytes that it will use when serialized to JSON. */ -export function getJsonSerializableInfo(value: unknown): [boolean, number] { +export function getJsonSerializableInfo( + value: unknown, + skipSizing = false, +): [boolean, number] { if (value === undefined) { // Return zero for undefined, since these are omitted from JSON serialization return [true, 0]; } else if (value === null) { // Return already specified constant size for null (special object) - return [true, JsonSize.NULL]; + return [true, skipSizing ? 0 : JsonSize.NULL]; } // Check and calculate sizes for basic types // eslint-disable-next-line default-case switch (typeof value) { case 'string': - return [true, calculateStringSize(value) + JsonSize.QUOTE * 2]; + return [ + true, + skipSizing ? 0 : calculateStringSize(value) + JsonSize.QUOTE * 2, + ]; case 'boolean': + if (skipSizing) { + return [true, 0]; + } return [true, value ? JsonSize.TRUE : JsonSize.FALSE]; case 'number': - return [true, calculateNumberSize(value)]; + return [true, skipSizing ? 0 : calculateNumberSize(value)]; } // If object is not plain and cannot be serialized properly, @@ -317,29 +327,38 @@ export function getJsonSerializableInfo(value: unknown): [boolean, number] { (sum, [key, nestedValue], idx, arr) => { // Recursively process next nested object or primitive type // eslint-disable-next-line prefer-const - let [valid, size] = getJsonSerializableInfo(nestedValue); + let [valid, size] = getJsonSerializableInfo(nestedValue, skipSizing); if (!valid) { throw new Error(); } // If the size is 0, the value is undefined and undefined in an array // when serialized will be replaced with null - if (size === 0 && Array.isArray(value)) { + if (!skipSizing && size === 0 && Array.isArray(value)) { size = JsonSize.NULL; } - // Objects will have be serialized with "key": value, therefore we include the key in the calculation here - const keySize = Array.isArray(value) - ? 0 - : key.length + JsonSize.COMMA + JsonSize.COLON * 2; - const separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; + // Objects will have be serialized with "key": value, + // therefore we include the key in the calculation here + let keySize: any; + if (skipSizing) { + keySize = 0; + } else { + keySize = Array.isArray(value) + ? 0 + : key.length + JsonSize.COMMA + JsonSize.COLON * 2; + } + let separator = 0; + if (!skipSizing) { + separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; + } // If the size is 0, that means the object is undefined and // the rest of the object structure will be omitted return size === 0 ? sum : sum + keySize + size + separator; }, // Starts at 2 because the serialized JSON string data (plain text) // will minimally contain {}/[] - JsonSize.WRAPPER * 2, + skipSizing ? 0 : JsonSize.WRAPPER * 2, ), ]; } catch (_) { From 9fc439bd48d02998ba6492e8b4eb53ccac56b000 Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 30 Jun 2022 17:46:24 +0200 Subject: [PATCH 12/33] Add algorithm improvements and new test data --- src/json.test.ts | 4 ++-- src/misc.test.ts | 5 +++++ src/misc.ts | 2 +- src/test.data.ts | 1 + 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 4186a106..63490ca6 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -358,10 +358,10 @@ describe('json', () => { ).toStrictEqual([true, 73]); }); - it('should return true for serialization and 1018 for a size of a complex nested object', () => { + it('should return true for serialization and 1051 for a size of a complex nested object', () => { expect(getJsonSerializableInfo(complexObject)).toStrictEqual([ true, - 1018, + 1051, ]); }); diff --git a/src/misc.test.ts b/src/misc.test.ts index ebfc7385..43554450 100644 --- a/src/misc.test.ts +++ b/src/misc.test.ts @@ -175,6 +175,11 @@ describe('miscellaneous', () => { const str = 'ašbđcćdčež'; expect(calculateStringSize(str)).toBe(15); }); + + it('should return 10 for a size of special characters', () => { + const str = '"\\\n\r\t'; + expect(calculateStringSize(str)).toBe(10); + }); }); describe('calculateNumberSize', () => { diff --git a/src/misc.ts b/src/misc.ts index 57a5274b..1aeca393 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -161,7 +161,7 @@ export function calculateStringSize(value: string) { } // Detect characters that need backslash escape - const re = /\\|'/gu; + const re = /"|\\|\n|\r|\t/gu; size += (value.match(re) || []).length; return size; diff --git a/src/test.data.ts b/src/test.data.ts index 59ded2ce..1c8f4902 100644 --- a/src/test.data.ts +++ b/src/test.data.ts @@ -48,6 +48,7 @@ export const complexObject = { '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~', utf8: 'šđćčžЀЂЇЄЖФћΣΩδλ', mixed: 'ABCDEFGHIJ KLMNOPQRST UVWXYZšđćč žЀЂЇЄЖФћΣΩδλ', + specialCharacters: '"\\\n\r\t', }, specialObjectsTypesAndValues: { t: [true, true, true], From a1c2265d1255993b8e42a8a30123ff8a97269a3e Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 4 Jul 2022 11:51:35 +0200 Subject: [PATCH 13/33] Minor refactoring of calculateStringSize function --- src/misc.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/misc.ts b/src/misc.ts index 1aeca393..ceb00b2c 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -162,9 +162,8 @@ export function calculateStringSize(value: string) { // Detect characters that need backslash escape const re = /"|\\|\n|\r|\t/gu; - size += (value.match(re) || []).length; - return size; + return size + (value.match(re) || []).length; } /** From 7fd7f441d8e003f3947893d8ca0794467b0f9eec Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 4 Jul 2022 13:34:43 +0200 Subject: [PATCH 14/33] Minor refactoring of isASCII function --- src/misc.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/misc.ts b/src/misc.ts index ceb00b2c..42dc5a00 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -134,14 +134,13 @@ export function isPlainObject(value: unknown): value is PlainObject { } /** - * Check if character or string is ASCII. + * Check if character is ASCII. * - * @param value - String or character. - * @returns Boolean, true if value is ASCII, false if not. + * @param character - Character. + * @returns Boolean, true if character code is ASCII, false if not. */ -export function isASCII(value: string) { - // eslint-disable-next-line no-control-regex,require-unicode-regexp - return /^[\x00-\x7F]*$/.test(value); +export function isASCII(character: string) { + return character.charCodeAt(0) <= 127; } /** From 72fe7a3ef6c8157f57edc33109dc8197e9f2229d Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 4 Jul 2022 14:04:35 +0200 Subject: [PATCH 15/33] Minor refactoring of skip sizing feature --- src/json.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/json.ts b/src/json.ts index 62d3480d..bc1b42f4 100644 --- a/src/json.ts +++ b/src/json.ts @@ -332,6 +332,10 @@ export function getJsonSerializableInfo( throw new Error(); } + if (skipSizing) { + return 0; + } + // If the size is 0, the value is undefined and undefined in an array // when serialized will be replaced with null if (!skipSizing && size === 0 && Array.isArray(value)) { @@ -340,18 +344,11 @@ export function getJsonSerializableInfo( // Objects will have be serialized with "key": value, // therefore we include the key in the calculation here - let keySize: any; - if (skipSizing) { - keySize = 0; - } else { - keySize = Array.isArray(value) - ? 0 - : key.length + JsonSize.COMMA + JsonSize.COLON * 2; - } - let separator = 0; - if (!skipSizing) { - separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; - } + const keySize = Array.isArray(value) + ? 0 + : key.length + JsonSize.COMMA + JsonSize.COLON * 2; + + const separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; // If the size is 0, that means the object is undefined and // the rest of the object structure will be omitted return size === 0 ? sum : sum + keySize + size + separator; From 0abc592632c64e5942b24098eb94babbdc2cefdb Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 4 Jul 2022 16:56:26 +0200 Subject: [PATCH 16/33] Add unit tests for circular structures --- src/json.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/json.test.ts b/src/json.test.ts index 63490ca6..113d9b99 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -394,5 +394,29 @@ describe('json', () => { getJsonSerializableInfo(nonSerializableNestedObject, true), ).toStrictEqual([false, 0]); }); + + it('should return false for serialization and 0 for a size when checking circular structure with an array', () => { + const arr: any[][] = []; + arr[0] = arr; + const circularStructure = { + // eslint-disable-next-line no-invalid-this + value: arr, + }; + expect(getJsonSerializableInfo(circularStructure, true)).toStrictEqual([ + false, + 0, + ]); + }); + + it('should return false for serialization and 0 for a size when checking circular structure with an object', () => { + const circularStructure = { + value: {}, + }; + circularStructure.value = circularStructure; + expect(getJsonSerializableInfo(circularStructure, true)).toStrictEqual([ + false, + 0, + ]); + }); }); }); From c8db2f60f5309395c3810abe229631797174493b Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 4 Jul 2022 17:05:03 +0200 Subject: [PATCH 17/33] Add test improvement with escape characters --- src/json.test.ts | 4 ++-- src/test.data.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 113d9b99..cc73e338 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -358,10 +358,10 @@ describe('json', () => { ).toStrictEqual([true, 73]); }); - it('should return true for serialization and 1051 for a size of a complex nested object', () => { + it('should return true for serialization and 1153 for a size of a complex nested object', () => { expect(getJsonSerializableInfo(complexObject)).toStrictEqual([ true, - 1051, + 1153, ]); }); diff --git a/src/test.data.ts b/src/test.data.ts index 1c8f4902..62287818 100644 --- a/src/test.data.ts +++ b/src/test.data.ts @@ -49,6 +49,8 @@ export const complexObject = { utf8: 'šđćčžЀЂЇЄЖФћΣΩδλ', mixed: 'ABCDEFGHIJ KLMNOPQRST UVWXYZšđćč žЀЂЇЄЖФћΣΩδλ', specialCharacters: '"\\\n\r\t', + stringWithSpecialEscapedCharacters: + "this\nis\nsome\"'string\r\nwith\tspecial\\escaped\tcharacters'", }, specialObjectsTypesAndValues: { t: [true, true, true], From 87484b29a413864e2a2dfad48b831a897da019ae Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 4 Jul 2022 17:33:59 +0200 Subject: [PATCH 18/33] Code refactoring --- src/json.ts | 8 +++++--- src/misc.ts | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/json.ts b/src/json.ts index bc1b42f4..3d2da540 100644 --- a/src/json.ts +++ b/src/json.ts @@ -281,8 +281,8 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { * * @param value - A potential JSON serializable value. * @param skipSizing - Skip JSON size calculation (default: false). - * @returns A tuple containing a boolean that signals whether the value was serializable and - * a number of bytes that it will use when serialized to JSON. + * @returns A tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether + * the value was serializable and a number of bytes that it will use when serialized to JSON. */ export function getJsonSerializableInfo( value: unknown, @@ -329,7 +329,9 @@ export function getJsonSerializableInfo( // eslint-disable-next-line prefer-const let [valid, size] = getJsonSerializableInfo(nestedValue, skipSizing); if (!valid) { - throw new Error(); + throw new Error( + 'JSON validation did not pass. Validation process stopped.', + ); } if (skipSizing) { diff --git a/src/misc.ts b/src/misc.ts index 42dc5a00..cd6d2ef2 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -114,7 +114,8 @@ export enum JsonSize { * Check if the value is plain object. * * @param value - Value to be checked. - * @returns Boolean. + * @returns True if an object is the plain JavaScript object, + * false if the object is not plain (e.g. function). */ export function isPlainObject(value: unknown): value is PlainObject { if (typeof value !== 'object' || value === null) { @@ -137,7 +138,7 @@ export function isPlainObject(value: unknown): value is PlainObject { * Check if character is ASCII. * * @param character - Character. - * @returns Boolean, true if character code is ASCII, false if not. + * @returns True if a character code is ASCII, false if not. */ export function isASCII(character: string) { return character.charCodeAt(0) <= 127; @@ -162,7 +163,7 @@ export function calculateStringSize(value: string) { // Detect characters that need backslash escape const re = /"|\\|\n|\r|\t/gu; - return size + (value.match(re) || []).length; + return size + (value.match(re) ?? []).length; } /** From ee6d28f18b0ce7870d1c88f7172a87b19b62db37 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 5 Jul 2022 11:23:47 +0200 Subject: [PATCH 19/33] Code refactoring & improvements --- src/json.ts | 20 ++++++++++---------- src/misc.ts | 28 ++++++++++++---------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/json.ts b/src/json.ts index 3d2da540..29bcff21 100644 --- a/src/json.ts +++ b/src/json.ts @@ -279,21 +279,21 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { * * This function assumes the encoding of the JSON is done in UTF-8. * - * @param value - A potential JSON serializable value. + * @param value - Potential JSON serializable value. * @param skipSizing - Skip JSON size calculation (default: false). - * @returns A tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether + * @returns Tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether * the value was serializable and a number of bytes that it will use when serialized to JSON. */ export function getJsonSerializableInfo( value: unknown, skipSizing = false, -): [boolean, number] { +): [isValid: boolean, plainTextSizeInBytes: number] { if (value === undefined) { // Return zero for undefined, since these are omitted from JSON serialization return [true, 0]; } else if (value === null) { // Return already specified constant size for null (special object) - return [true, skipSizing ? 0 : JsonSize.NULL]; + return [true, skipSizing ? 0 : JsonSize.Null]; } // Check and calculate sizes for basic types @@ -302,13 +302,13 @@ export function getJsonSerializableInfo( case 'string': return [ true, - skipSizing ? 0 : calculateStringSize(value) + JsonSize.QUOTE * 2, + skipSizing ? 0 : calculateStringSize(value) + JsonSize.Quote * 2, ]; case 'boolean': if (skipSizing) { return [true, 0]; } - return [true, value ? JsonSize.TRUE : JsonSize.FALSE]; + return [true, value ? JsonSize.True : JsonSize.False]; case 'number': return [true, skipSizing ? 0 : calculateNumberSize(value)]; } @@ -341,23 +341,23 @@ export function getJsonSerializableInfo( // If the size is 0, the value is undefined and undefined in an array // when serialized will be replaced with null if (!skipSizing && size === 0 && Array.isArray(value)) { - size = JsonSize.NULL; + size = JsonSize.Null; } // Objects will have be serialized with "key": value, // therefore we include the key in the calculation here const keySize = Array.isArray(value) ? 0 - : key.length + JsonSize.COMMA + JsonSize.COLON * 2; + : key.length + JsonSize.Comma + JsonSize.Colon * 2; - const separator = idx < arr.length - 1 ? JsonSize.COMMA : 0; + const separator = idx < arr.length - 1 ? JsonSize.Comma : 0; // If the size is 0, that means the object is undefined and // the rest of the object structure will be omitted return size === 0 ? sum : sum + keySize + size + separator; }, // Starts at 2 because the serialized JSON string data (plain text) // will minimally contain {}/[] - skipSizing ? 0 : JsonSize.WRAPPER * 2, + skipSizing ? 0 : JsonSize.Wrapper * 2, ), ]; } catch (_) { diff --git a/src/misc.ts b/src/misc.ts index cd6d2ef2..5063a949 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -99,15 +99,13 @@ export type PlainObject = Record; * Predefined sizes (in Bytes) of specific parts of JSON structure. */ export enum JsonSize { - NULL = 4, - COMMA = 1, - WRAPPER = 1, - TRUE = 4, - FALSE = 5, - ZERO = 1, - QUOTE = 1, - COLON = 1, - DOT = 1, + Null = 4, + Comma = 1, + Wrapper = 1, + True = 4, + False = 5, + Quote = 1, + Colon = 1, } /** @@ -150,15 +148,13 @@ export function isASCII(character: string) { * @param value - String value to calculate size. * @returns Number of bytes used to store whole string value. */ -export function calculateStringSize(value: string) { - let size = 0; - for (const character of value) { +export function calculateStringSize(value: string): number { + const size = value.split('').reduce((total, character) => { if (isASCII(character)) { - size += 1; - } else { - size += 2; + return total + 1; } - } + return total + 2; + }, 0); // Detect characters that need backslash escape const re = /"|\\|\n|\r|\t/gu; From a573df7b6b0e7823c4acdc623a6a4489f39f2025 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 5 Jul 2022 11:33:32 +0200 Subject: [PATCH 20/33] Small code refactoring (review answer) --- src/json.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/json.ts b/src/json.ts index 29bcff21..a09d5da1 100644 --- a/src/json.ts +++ b/src/json.ts @@ -353,7 +353,10 @@ export function getJsonSerializableInfo( const separator = idx < arr.length - 1 ? JsonSize.Comma : 0; // If the size is 0, that means the object is undefined and // the rest of the object structure will be omitted - return size === 0 ? sum : sum + keySize + size + separator; + if (size === 0) { + return sum; + } + return sum + keySize + size + separator; }, // Starts at 2 because the serialized JSON string data (plain text) // will minimally contain {}/[] From 537e2364ea550ee3df7f7d9977f3f7f0eb679498 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 5 Jul 2022 15:18:19 +0200 Subject: [PATCH 21/33] Another minor code refactoring (review answer) --- src/json.ts | 14 ++++++++------ src/misc.ts | 11 +++++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/json.ts b/src/json.ts index a09d5da1..937c9015 100644 --- a/src/json.ts +++ b/src/json.ts @@ -340,10 +340,16 @@ export function getJsonSerializableInfo( // If the size is 0, the value is undefined and undefined in an array // when serialized will be replaced with null - if (!skipSizing && size === 0 && Array.isArray(value)) { + if (size === 0 && Array.isArray(value)) { size = JsonSize.Null; } + // If the size is 0, that means the object is undefined and + // the rest of the object structure will be omitted + if (size === 0) { + return sum; + } + // Objects will have be serialized with "key": value, // therefore we include the key in the calculation here const keySize = Array.isArray(value) @@ -351,11 +357,7 @@ export function getJsonSerializableInfo( : key.length + JsonSize.Comma + JsonSize.Colon * 2; const separator = idx < arr.length - 1 ? JsonSize.Comma : 0; - // If the size is 0, that means the object is undefined and - // the rest of the object structure will be omitted - if (size === 0) { - return sum; - } + return sum + keySize + size + separator; }, // Starts at 2 because the serialized JSON string data (plain text) diff --git a/src/misc.ts b/src/misc.ts index 5063a949..2938d58c 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -108,6 +108,11 @@ export enum JsonSize { Colon = 1, } +/** + * Regular expression with pattern matching for (special) escaped characters. + */ +export const ESCAPE_CHARACTERS_REGEXP = /"|\\|\n|\r|\t/gu; + /** * Check if the value is plain object. * @@ -156,10 +161,8 @@ export function calculateStringSize(value: string): number { return total + 2; }, 0); - // Detect characters that need backslash escape - const re = /"|\\|\n|\r|\t/gu; - - return size + (value.match(re) ?? []).length; + // Also detect characters that need backslash escape + return size + (value.match(ESCAPE_CHARACTERS_REGEXP) ?? []).length; } /** From a05309484a3ccdf2ad459224c4d7209f3ccfba73 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 5 Jul 2022 15:24:43 +0200 Subject: [PATCH 22/33] Remove useless comment --- src/json.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/json.test.ts b/src/json.test.ts index cc73e338..fdf2a8d0 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -399,7 +399,6 @@ describe('json', () => { const arr: any[][] = []; arr[0] = arr; const circularStructure = { - // eslint-disable-next-line no-invalid-this value: arr, }; expect(getJsonSerializableInfo(circularStructure, true)).toStrictEqual([ From e16f51e422017504f32e13b5244c3caf1f83e116 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 5 Jul 2022 15:46:37 +0200 Subject: [PATCH 23/33] Add more tests for serialization checks of functions and symbols --- src/json.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/json.test.ts b/src/json.test.ts index fdf2a8d0..41e4f2ef 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -417,5 +417,27 @@ describe('json', () => { 0, ]); }); + + it('should return false for serialization and 0 for a size when checking object containing symbols', () => { + const objectContainingSymbols = { + mySymbol: Symbol('MySymbol'), + }; + expect(getJsonSerializableInfo(objectContainingSymbols)).toStrictEqual([ + false, + 0, + ]); + }); + + it('should return false for serialization and 0 for a size when checking an array containing a function', () => { + const objectContainingFunction = [ + function () { + return 'whatever'; + }, + ]; + expect(getJsonSerializableInfo(objectContainingFunction)).toStrictEqual([ + false, + 0, + ]); + }); }); }); From 2237edd27d785eb76f591b364440129e27bd62d8 Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 5 Jul 2022 17:25:41 +0200 Subject: [PATCH 24/33] Add specific approach for handling Date objects --- src/json.test.ts | 15 +++++++++++++-- src/json.ts | 16 ++++++++++++++++ src/test.data.ts | 5 +++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 41e4f2ef..64772708 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -358,13 +358,24 @@ describe('json', () => { ).toStrictEqual([true, 73]); }); - it('should return true for serialization and 1153 for a size of a complex nested object', () => { + it('should return true for serialization and 1259 for a size of a complex nested object', () => { expect(getJsonSerializableInfo(complexObject)).toStrictEqual([ true, - 1153, + 1259, ]); }); + it('should return true for serialization and 107 for a size of an object containing Date object', () => { + const dateObjects = { + dates: { + someDate: new Date(), + someOther: new Date(2022, 0, 2, 15, 4, 5), + invalidDate: new Date('bad-date-format'), + }, + }; + expect(getJsonSerializableInfo(dateObjects)).toStrictEqual([true, 107]); + }); + it('should return false for serialization and 0 for size when non-serializable object was provided', () => { const valueToSerialize = { value: new Set(), diff --git a/src/json.ts b/src/json.ts index 937c9015..c3581102 100644 --- a/src/json.ts +++ b/src/json.ts @@ -313,6 +313,22 @@ export function getJsonSerializableInfo( return [true, skipSizing ? 0 : calculateNumberSize(value)]; } + // Check if value is Date and handle it since Date is + // specific complex object that is JSON serializable + if (value instanceof Date) { + if (skipSizing) { + return [true, 0]; + } + const jsonSerializedDate = value.toJSON(); + return [ + true, + // Note: Invalid dates will serialize to null + jsonSerializedDate === null + ? JsonSize.Null + : calculateStringSize(jsonSerializedDate) + JsonSize.Quote * 2, + ]; + } + // If object is not plain and cannot be serialized properly, // stop here and return false for serialization if (!isPlainObject(value) && !Array.isArray(value)) { diff --git a/src/test.data.ts b/src/test.data.ts index 62287818..6300bec1 100644 --- a/src/test.data.ts +++ b/src/test.data.ts @@ -75,6 +75,11 @@ export const complexObject = { t: true, f: false, }, + dates: { + someDate: new Date(), + someOther: new Date(2022, 0, 2, 15, 4, 5), + invalidDate: new Date('bad-date-format'), + }, }, }, }, From b0d8b6aaacc6ab1a506be7736700355cb597582b Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 7 Jul 2022 12:19:50 +0200 Subject: [PATCH 25/33] Add ECMA TC39 (test262) test scenarios and improve algorithm --- src/json.test.ts | 160 +++++++++++++++++++++++++++++++++++++++++++---- src/json.ts | 42 +++++++++---- src/test.data.ts | 4 +- 3 files changed, 181 insertions(+), 25 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 64772708..739d86e6 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -376,18 +376,11 @@ describe('json', () => { expect(getJsonSerializableInfo(dateObjects)).toStrictEqual([true, 107]); }); - it('should return false for serialization and 0 for size when non-serializable object was provided', () => { - const valueToSerialize = { - value: new Set(), - }; - - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ - false, - 0, - ]); - }); - it('should return false for serialization and 0 for size when non-serializable nested object was provided', () => { + expect( + nonSerializableNestedObject.levelOne.levelTwo.levelThree.levelFour.levelFive(), + ).toStrictEqual('anything'); + expect( getJsonSerializableInfo(nonSerializableNestedObject), ).toStrictEqual([false, 0]); @@ -450,5 +443,150 @@ describe('json', () => { 0, ]); }); + + it('should return true or false for validity depending on the test scenario from ECMA TC39 (test262)', () => { + // This test will perform a series of validation assertions. + // These tests are taken from ECMA TC39 (test262) test scenarios used + // for testing the JSON.stringify function. + // https://github.com/tc39/test262/tree/main/test/built-ins/JSON/stringify + + // Value: array circular + const direct: any = []; + direct.push(direct); + + expect(getJsonSerializableInfo(direct)).toStrictEqual([false, 0]); + + const indirect: any = []; + indirect.push([[indirect]]); + + expect(getJsonSerializableInfo(indirect)).toStrictEqual([false, 0]); + + // Value: array proxy revoked + const handle = Proxy.revocable([], {}); + handle.revoke(); + + expect(getJsonSerializableInfo(handle.proxy)).toStrictEqual([false, 0]); + expect(getJsonSerializableInfo([[[handle.proxy]]])).toStrictEqual([ + false, + 0, + ]); + + // Value: array proxy + const arrayProxy = new Proxy([], { + get(_target, key) { + if (key === 'length') { + return 2; + } + return Number(key); + }, + }); + + expect(getJsonSerializableInfo(arrayProxy, true)).toStrictEqual([ + true, + 0, + ]); + + expect(getJsonSerializableInfo([[arrayProxy]], true)).toStrictEqual([ + true, + 0, + ]); + + const arrayProxyProxy = new Proxy(arrayProxy, {}); + expect(getJsonSerializableInfo([[arrayProxyProxy]], true)).toStrictEqual([ + true, + 0, + ]); + + // Value: Boolean object + // eslint-disable-next-line no-new-wrappers + expect(getJsonSerializableInfo(new Boolean(true), true)).toStrictEqual([ + true, + 0, + ]); + + expect( + // eslint-disable-next-line no-new-wrappers + getJsonSerializableInfo({ key: new Boolean(false) }, true), + ).toStrictEqual([true, 0]); + + expect( + // eslint-disable-next-line no-new-wrappers + getJsonSerializableInfo(new Boolean(false)), + ).toStrictEqual([true, 5]); + + expect( + // eslint-disable-next-line no-new-wrappers + getJsonSerializableInfo(new Boolean(true)), + ).toStrictEqual([true, 4]); + + // Value: number negative zero + expect(getJsonSerializableInfo(-0, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(['-0', 0, -0], true)).toStrictEqual([ + true, + 0, + ]); + + expect(getJsonSerializableInfo({ key: -0 }, true)).toStrictEqual([ + true, + 0, + ]); + + // Value: number non finite + expect(getJsonSerializableInfo(Infinity, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo({ key: -Infinity }, true)).toStrictEqual([ + true, + 0, + ]); + expect(getJsonSerializableInfo([NaN], true)).toStrictEqual([true, 0]); + + // Value: object abrupt + expect( + getJsonSerializableInfo( + { + get key() { + throw new Error(); + }, + }, + true, + ), + ).toStrictEqual([false, 0]); + + // Value: Number object + // eslint-disable-next-line no-new-wrappers + expect(getJsonSerializableInfo(new Number(3.14), true)).toStrictEqual([ + true, + 0, + ]); + + // eslint-disable-next-line no-new-wrappers + expect(getJsonSerializableInfo(new Number(3.14))).toStrictEqual([ + true, + 4, + ]); + + // Value: object circular + const directObject = { prop: {} }; + directObject.prop = directObject; + + expect(getJsonSerializableInfo(directObject, false)).toStrictEqual([ + false, + 0, + ]); + + const indirectObject = { + p1: { + p2: { + get p3() { + return indirectObject; + }, + }, + }, + }; + + expect(getJsonSerializableInfo(indirectObject, false)).toStrictEqual([ + false, + 0, + ]); + }); }); }); diff --git a/src/json.ts b/src/json.ts index c3581102..b8ca29ab 100644 --- a/src/json.ts +++ b/src/json.ts @@ -299,6 +299,8 @@ export function getJsonSerializableInfo( // Check and calculate sizes for basic types // eslint-disable-next-line default-case switch (typeof value) { + case 'function': + return [false, 0]; case 'string': return [ true, @@ -313,20 +315,34 @@ export function getJsonSerializableInfo( return [true, skipSizing ? 0 : calculateNumberSize(value)]; } - // Check if value is Date and handle it since Date is - // specific complex object that is JSON serializable - if (value instanceof Date) { - if (skipSizing) { - return [true, 0]; + // Handle specific complex objects that can be serialized properly + try { + if (value instanceof Date) { + if (skipSizing) { + return [true, 0]; + } + const jsonSerializedDate = value.toJSON(); + return [ + true, + // Note: Invalid dates will serialize to null + jsonSerializedDate === null + ? JsonSize.Null + : calculateStringSize(jsonSerializedDate) + JsonSize.Quote * 2, + ]; + } else if (value instanceof Boolean) { + if (skipSizing) { + return [true, 0]; + } + // eslint-disable-next-line eqeqeq + return [true, value == true ? JsonSize.True : JsonSize.False]; + } else if (value instanceof Number) { + if (skipSizing) { + return [true, 0]; + } + return [true, value.toString().length]; } - const jsonSerializedDate = value.toJSON(); - return [ - true, - // Note: Invalid dates will serialize to null - jsonSerializedDate === null - ? JsonSize.Null - : calculateStringSize(jsonSerializedDate) + JsonSize.Quote * 2, - ]; + } catch (_) { + return [false, 0]; } // If object is not plain and cannot be serialized properly, diff --git a/src/test.data.ts b/src/test.data.ts index 6300bec1..c2e8f96b 100644 --- a/src/test.data.ts +++ b/src/test.data.ts @@ -90,7 +90,9 @@ export const nonSerializableNestedObject = { levelTwo: { levelThree: { levelFour: { - levelFive: new Set(), + levelFive: () => { + return 'anything'; + }, }, }, }, From b87f4595a7041ea6d12ef7cdd44f0813549b59be Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 7 Jul 2022 15:46:28 +0200 Subject: [PATCH 26/33] Add another batch of ECMA TC39 (test262) test scenarios --- src/json.test.ts | 180 +++++++++++++++++++++++++++++++++++++++++++++++ src/json.ts | 5 ++ 2 files changed, 185 insertions(+) diff --git a/src/json.test.ts b/src/json.test.ts index 739d86e6..ce839c2b 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -587,6 +587,186 @@ describe('json', () => { false, 0, ]); + + // Value: object proxy + const objectProxy = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + return { + value: 1, + writable: true, + enumerable: true, + configurable: true, + }; + }, + get() { + return 1; + }, + ownKeys() { + return ['a', 'b']; + }, + }, + ); + + expect(getJsonSerializableInfo(objectProxy, true)).toStrictEqual([ + true, + 0, + ]); + + expect( + getJsonSerializableInfo({ l1: { l2: objectProxy } }, true), + ).toStrictEqual([true, 0]); + + // Value: object proxy revoked + const handleForObjectProxy = Proxy.revocable({}, {}); + handleForObjectProxy.revoke(); + expect( + getJsonSerializableInfo(handleForObjectProxy.proxy, true), + ).toStrictEqual([false, 0]); + + expect( + getJsonSerializableInfo({ a: { b: handleForObjectProxy.proxy } }, true), + ).toStrictEqual([false, 0]); + + // Value: primitive top level + expect(getJsonSerializableInfo(null, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(true, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(false, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo('str', true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(123, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(undefined, true)).toStrictEqual([true, 0]); + + // Value: string escape ASCII + const charToJson = { + '"': '\\"', + '\\': '\\\\', + '\x00': '\\u0000', + '\x01': '\\u0001', + '\x02': '\\u0002', + '\x03': '\\u0003', + '\x04': '\\u0004', + '\x05': '\\u0005', + '\x06': '\\u0006', + '\x07': '\\u0007', + '\x08': '\\b', + '\x09': '\\t', + '\x0A': '\\n', + '\x0B': '\\u000b', + '\x0C': '\\f', + '\x0D': '\\r', + '\x0E': '\\u000e', + '\x0F': '\\u000f', + '\x10': '\\u0010', + '\x11': '\\u0011', + '\x12': '\\u0012', + '\x13': '\\u0013', + '\x14': '\\u0014', + '\x15': '\\u0015', + '\x16': '\\u0016', + '\x17': '\\u0017', + '\x18': '\\u0018', + '\x19': '\\u0019', + '\x1A': '\\u001a', + '\x1B': '\\u001b', + '\x1C': '\\u001c', + '\x1D': '\\u001d', + '\x1E': '\\u001e', + '\x1F': '\\u001f', + }; + + const chars = Object.keys(charToJson).join(''); + const charsReversed = Object.keys(charToJson).reverse().join(''); + const jsonChars = Object.values(charToJson).join(''); + const jsonCharsReversed = Object.values(charToJson).reverse().join(''); + + expect(getJsonSerializableInfo(charToJson, true)).toStrictEqual([ + true, + 0, + ]); + + // eslint-disable-next-line guard-for-in + for (const char in charToJson) { + expect(getJsonSerializableInfo(char, true)).toStrictEqual([true, 0]); + } + + expect(getJsonSerializableInfo(chars, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(charsReversed, true)).toStrictEqual([ + true, + 0, + ]); + expect(getJsonSerializableInfo(jsonChars, true)).toStrictEqual([true, 0]); + expect(getJsonSerializableInfo(jsonCharsReversed, true)).toStrictEqual([ + true, + 0, + ]); + + // Value: string escape unicode + const stringEscapeUnicode: string[] = [ + '\uD834', + '\uDF06', + '\uD834\uDF06', + '\uD834\uD834\uDF06\uD834', + '\uD834\uD834\uDF06\uDF06', + '\uDF06\uD834\uDF06\uD834', + '\uDF06\uD834\uDF06\uDF06', + '\uDF06\uD834', + '\uD834\uDF06\uD834\uD834', + '\uD834\uDF06\uD834\uDF06', + '\uDF06\uDF06\uD834\uD834', + '\uDF06\uDF06\uD834\uDF06', + ]; + + // eslint-disable-next-line guard-for-in + for (const strUnicode in stringEscapeUnicode) { + expect(getJsonSerializableInfo(strUnicode, true)).toStrictEqual([ + true, + 0, + ]); + } + + // Value: string object + // eslint-disable-next-line no-new-wrappers + expect(getJsonSerializableInfo(new String('str'), true)).toStrictEqual([ + true, + 0, + ]); + + // eslint-disable-next-line no-new-wrappers + expect(getJsonSerializableInfo(new String('str'))).toStrictEqual([ + true, + 5, + ]); + + // Value: toJSON not a function + expect(getJsonSerializableInfo({ toJSON: null }, true)).toStrictEqual([ + true, + 0, + ]); + + expect(getJsonSerializableInfo({ toJSON: false }, true)).toStrictEqual([ + true, + 0, + ]); + + expect(getJsonSerializableInfo({ toJSON: [] }, true)).toStrictEqual([ + true, + 0, + ]); + + // Value: toJSON object circular + const obj = { + toJSON() { + return {}; + }, + }; + const circular = { prop: obj }; + + obj.toJSON = function () { + return circular; + }; + + expect(getJsonSerializableInfo(circular, true)).toStrictEqual([false, 0]); }); }); }); diff --git a/src/json.ts b/src/json.ts index b8ca29ab..cf23630a 100644 --- a/src/json.ts +++ b/src/json.ts @@ -340,6 +340,11 @@ export function getJsonSerializableInfo( return [true, 0]; } return [true, value.toString().length]; + } else if (value instanceof String) { + if (skipSizing) { + return [true, 0]; + } + return [true, calculateStringSize(value.toString()) + JsonSize.Quote * 2]; } } catch (_) { return [false, 0]; From 1c1586bb4a22ceb2cd4d9bf18629c3b2e646a40b Mon Sep 17 00:00:00 2001 From: david0xd Date: Thu, 7 Jul 2022 19:02:07 +0200 Subject: [PATCH 27/33] Fix types in tests --- src/json.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index ce839c2b..b7066a13 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -400,7 +400,7 @@ describe('json', () => { }); it('should return false for serialization and 0 for a size when checking circular structure with an array', () => { - const arr: any[][] = []; + const arr: unknown[] = []; arr[0] = arr; const circularStructure = { value: arr, @@ -451,12 +451,12 @@ describe('json', () => { // https://github.com/tc39/test262/tree/main/test/built-ins/JSON/stringify // Value: array circular - const direct: any = []; + const direct: unknown[] = []; direct.push(direct); expect(getJsonSerializableInfo(direct)).toStrictEqual([false, 0]); - const indirect: any = []; + const indirect: unknown[] = []; indirect.push([[indirect]]); expect(getJsonSerializableInfo(indirect)).toStrictEqual([false, 0]); From 6913a2c59d76c72df0db346f6d6deaaac65c520e Mon Sep 17 00:00:00 2001 From: david0xd Date: Fri, 8 Jul 2022 18:10:33 +0200 Subject: [PATCH 28/33] Add optimized check for circular structures --- src/json.test.ts | 176 +++++++++++++++++++++++++++++++---------------- src/json.ts | 72 +++++++++++++++++++ 2 files changed, 189 insertions(+), 59 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index b7066a13..a3aaa651 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -12,6 +12,7 @@ import { assertIsJsonRpcSuccess, getJsonRpcIdValidator, getJsonSerializableInfo, + isObjectContainingCircularStructure, isJsonRpcFailure, isJsonRpcNotification, isJsonRpcRequest, @@ -19,6 +20,7 @@ import { isValidJson, jsonrpc2, JsonRpcError, + validateJsonAndGetSize, } from '.'; const getError = () => { @@ -399,29 +401,6 @@ describe('json', () => { ).toStrictEqual([false, 0]); }); - it('should return false for serialization and 0 for a size when checking circular structure with an array', () => { - const arr: unknown[] = []; - arr[0] = arr; - const circularStructure = { - value: arr, - }; - expect(getJsonSerializableInfo(circularStructure, true)).toStrictEqual([ - false, - 0, - ]); - }); - - it('should return false for serialization and 0 for a size when checking circular structure with an object', () => { - const circularStructure = { - value: {}, - }; - circularStructure.value = circularStructure; - expect(getJsonSerializableInfo(circularStructure, true)).toStrictEqual([ - false, - 0, - ]); - }); - it('should return false for serialization and 0 for a size when checking object containing symbols', () => { const objectContainingSymbols = { mySymbol: Symbol('MySymbol'), @@ -450,17 +429,6 @@ describe('json', () => { // for testing the JSON.stringify function. // https://github.com/tc39/test262/tree/main/test/built-ins/JSON/stringify - // Value: array circular - const direct: unknown[] = []; - direct.push(direct); - - expect(getJsonSerializableInfo(direct)).toStrictEqual([false, 0]); - - const indirect: unknown[] = []; - indirect.push([[indirect]]); - - expect(getJsonSerializableInfo(indirect)).toStrictEqual([false, 0]); - // Value: array proxy revoked const handle = Proxy.revocable([], {}); handle.revoke(); @@ -564,30 +532,6 @@ describe('json', () => { 4, ]); - // Value: object circular - const directObject = { prop: {} }; - directObject.prop = directObject; - - expect(getJsonSerializableInfo(directObject, false)).toStrictEqual([ - false, - 0, - ]); - - const indirectObject = { - p1: { - p2: { - get p3() { - return indirectObject; - }, - }, - }, - }; - - expect(getJsonSerializableInfo(indirectObject, false)).toStrictEqual([ - false, - 0, - ]); - // Value: object proxy const objectProxy = new Proxy( {}, @@ -753,6 +697,116 @@ describe('json', () => { true, 0, ]); + }); + }); + + describe('isContainingCircularStructure', () => { + it('should return false if value passed is not an object', () => { + expect( + isObjectContainingCircularStructure('valueThatIsNotAnObject'), + ).toBe(false); + }); + + it('should return false for an object that does not contain any circular references', () => { + expect(isObjectContainingCircularStructure(complexObject)).toBe(false); + }); + + it('should return true for an object that contains circular references', () => { + const circularStructure = { + value: {}, + }; + circularStructure.value = circularStructure; + expect(isObjectContainingCircularStructure(circularStructure)).toBe(true); + }); + + it('should return true for an array that contains circular references', () => { + const circularArray: unknown[] = []; + circularArray[0] = circularArray; + expect(isObjectContainingCircularStructure(circularArray)).toBe(true); + }); + + it('should return true for an object that contains nested circular references', () => { + const circularStructure = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: {}, + }, + }, + }, + }, + }; + circularStructure.levelOne.levelTwo.levelThree.levelFour.levelFive = circularStructure; + expect(isObjectContainingCircularStructure(circularStructure)).toBe(true); + }); + + it('should return true for an object that contains multiple nested circular references', () => { + const circularStructure = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: {}, + }, + }, + }, + }, + anotherOne: {}, + justAnotherOne: { + toAnotherOne: { + andAnotherOne: {}, + }, + }, + }; + circularStructure.levelOne.levelTwo.levelThree.levelFour.levelFive = circularStructure; + circularStructure.anotherOne = circularStructure; + circularStructure.justAnotherOne.toAnotherOne.andAnotherOne = circularStructure; + expect(isObjectContainingCircularStructure(circularStructure)).toBe(true); + }); + }); + + describe('validateJsonAndGetSize', () => { + it('should return false for validity when circular objects are passed to the function', () => { + // This test will perform a series of validation assertions. + // These tests are taken from ECMA TC39 (test262) test scenarios used + // for testing the JSON.stringify function. + // https://github.com/tc39/test262/tree/main/test/built-ins/JSON/stringify + + // Value: array circular + const direct: unknown[] = []; + direct.push(direct); + + expect(validateJsonAndGetSize(direct)).toStrictEqual([false, 0]); + + const indirect: unknown[] = []; + indirect.push([[indirect]]); + + expect(validateJsonAndGetSize(indirect)).toStrictEqual([false, 0]); + + // Value: object circular + const directObject = { prop: {} }; + directObject.prop = directObject; + + expect(getJsonSerializableInfo(directObject, false)).toStrictEqual([ + false, + 0, + ]); + + const indirectObject = { + p1: { + p2: { + get p3() { + return indirectObject; + }, + }, + }, + }; + + expect(getJsonSerializableInfo(indirectObject, false)).toStrictEqual([ + false, + 0, + ]); // Value: toJSON object circular const obj = { @@ -766,7 +820,11 @@ describe('json', () => { return circular; }; - expect(getJsonSerializableInfo(circular, true)).toStrictEqual([false, 0]); + expect(validateJsonAndGetSize(circular, true)).toStrictEqual([false, 0]); + }); + + it('should return true for serialization and 1259 for a size of a complex nested object', () => { + expect(validateJsonAndGetSize(complexObject)).toStrictEqual([true, 1259]); }); }); }); diff --git a/src/json.ts b/src/json.ts index cf23630a..f83786ad 100644 --- a/src/json.ts +++ b/src/json.ts @@ -277,6 +277,9 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { * Checks whether a value is JSON serializable and counts the total number * of bytes needed to store the serialized version of the value. * + * Important note: This function will not check for circular references. + * For circular reference check support, use validateJsonAndGetSize function. + * * This function assumes the encoding of the JSON is done in UTF-8. * * @param value - Potential JSON serializable value. @@ -406,3 +409,72 @@ export function getJsonSerializableInfo( return [false, 0]; } } + +/** + * Check for circular structures (e.g. object referencing itself). + * + * @param objectToBeChecked - Object that potentially can have circular references. + * @returns True if circular structure is detected, false otherwise. + */ +export function isObjectContainingCircularStructure( + objectToBeChecked: unknown, +): boolean { + if (typeof objectToBeChecked !== 'object') { + return false; + } + const seenObjects: unknown[] = []; + + /** + * Internal function for detection of circular structures. + * + * @param obj - Object that needs to be checked. + * @returns True if circular structure is detected, false otherwise. + */ + function detect(obj: unknown): boolean { + if (obj && typeof obj === 'object') { + if (seenObjects.indexOf(obj) !== -1) { + return true; + } + seenObjects.push(obj); + return Boolean( + Object.keys(obj).reduce((result, key) => { + if (result) { + return true; + } + + if (typeof obj[key as keyof unknown] !== 'object') { + return false; + } + + return detect(obj[key as keyof unknown]); + }, false), + ); + } + return false; + } + + return detect(objectToBeChecked); +} + +/** + * Checks whether a value is JSON serializable and counts the total number + * of bytes needed to store the serialized version of the value. + * + * Note: This is a wrapper function that will do check for circular structures. + * This function assumes the encoding of the JSON is done in UTF-8. + * + * @param value - Potential JSON serializable value. + * @param skipSizing - Skip JSON size calculation (default: false). + * @returns Tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether + * the value was serializable and a number of bytes that it will use when serialized to JSON. + */ +export function validateJsonAndGetSize( + value: unknown, + skipSizing = false, +): [isValid: boolean, plainTextSizeInBytes: number] { + if (isObjectContainingCircularStructure(value)) { + return [false, 0]; + } + + return getJsonSerializableInfo(value, skipSizing); +} From 31c7da6de85c514d710b39dd12d4b84dd47cd0a7 Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 11 Jul 2022 13:13:25 +0200 Subject: [PATCH 29/33] Change approach for circular object detection --- src/json.test.ts | 257 +++++++++++++++--------------------- src/json.ts | 329 ++++++++++++++++++++--------------------------- 2 files changed, 247 insertions(+), 339 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index a3aaa651..4717a9ef 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -11,8 +11,6 @@ import { assertIsJsonRpcRequest, assertIsJsonRpcSuccess, getJsonRpcIdValidator, - getJsonSerializableInfo, - isObjectContainingCircularStructure, isJsonRpcFailure, isJsonRpcNotification, isJsonRpcRequest, @@ -297,13 +295,13 @@ describe('json', () => { }); }); - describe('getJsonSerializableInfo', () => { + describe('validateJsonAndGetSize', () => { it('should return true for serialization and 10 for a size', () => { const valueToSerialize = { a: 'bc', }; - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + expect(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([ true, 10, ]); @@ -314,7 +312,7 @@ describe('json', () => { a: 1234, }; - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + expect(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([ true, 10, ]); @@ -325,7 +323,7 @@ describe('json', () => { a: 'bcšečf', }; - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ + expect(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([ true, 16, ]); @@ -336,35 +334,30 @@ describe('json', () => { a: undefined, }; - expect(getJsonSerializableInfo(valueToSerialize)).toStrictEqual([ - true, - 2, - ]); + expect(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([true, 2]); }); it('should return true for serialization and 25 for a size when some of the values are undefined', () => { expect( - getJsonSerializableInfo(objectMixedWithUndefinedValues), + validateJsonAndGetSize(objectMixedWithUndefinedValues), ).toStrictEqual([true, 25]); }); it('should return true for serialization and 17 for a size with mixed null and undefined in an array', () => { - expect( - getJsonSerializableInfo(arrayOfMixedSpecialObjects), - ).toStrictEqual([true, 51]); + expect(validateJsonAndGetSize(arrayOfMixedSpecialObjects)).toStrictEqual([ + true, + 51, + ]); }); it('should return true for serialization and 73 for a size, for an array of numbers', () => { expect( - getJsonSerializableInfo(arrayOfDifferentKindsOfNumbers), + validateJsonAndGetSize(arrayOfDifferentKindsOfNumbers), ).toStrictEqual([true, 73]); }); it('should return true for serialization and 1259 for a size of a complex nested object', () => { - expect(getJsonSerializableInfo(complexObject)).toStrictEqual([ - true, - 1259, - ]); + expect(validateJsonAndGetSize(complexObject)).toStrictEqual([true, 1259]); }); it('should return true for serialization and 107 for a size of an object containing Date object', () => { @@ -375,7 +368,7 @@ describe('json', () => { invalidDate: new Date('bad-date-format'), }, }; - expect(getJsonSerializableInfo(dateObjects)).toStrictEqual([true, 107]); + expect(validateJsonAndGetSize(dateObjects)).toStrictEqual([true, 107]); }); it('should return false for serialization and 0 for size when non-serializable nested object was provided', () => { @@ -384,12 +377,12 @@ describe('json', () => { ).toStrictEqual('anything'); expect( - getJsonSerializableInfo(nonSerializableNestedObject), + validateJsonAndGetSize(nonSerializableNestedObject), ).toStrictEqual([false, 0]); }); it('should return true for serialization and 0 for a size when sizing is skipped', () => { - expect(getJsonSerializableInfo(complexObject, true)).toStrictEqual([ + expect(validateJsonAndGetSize(complexObject, true)).toStrictEqual([ true, 0, ]); @@ -397,7 +390,7 @@ describe('json', () => { it('should return false for serialization and 0 for a size when sizing is skipped and non-serializable object was provided', () => { expect( - getJsonSerializableInfo(nonSerializableNestedObject, true), + validateJsonAndGetSize(nonSerializableNestedObject, true), ).toStrictEqual([false, 0]); }); @@ -405,7 +398,7 @@ describe('json', () => { const objectContainingSymbols = { mySymbol: Symbol('MySymbol'), }; - expect(getJsonSerializableInfo(objectContainingSymbols)).toStrictEqual([ + expect(validateJsonAndGetSize(objectContainingSymbols)).toStrictEqual([ false, 0, ]); @@ -417,7 +410,7 @@ describe('json', () => { return 'whatever'; }, ]; - expect(getJsonSerializableInfo(objectContainingFunction)).toStrictEqual([ + expect(validateJsonAndGetSize(objectContainingFunction)).toStrictEqual([ false, 0, ]); @@ -433,8 +426,8 @@ describe('json', () => { const handle = Proxy.revocable([], {}); handle.revoke(); - expect(getJsonSerializableInfo(handle.proxy)).toStrictEqual([false, 0]); - expect(getJsonSerializableInfo([[[handle.proxy]]])).toStrictEqual([ + expect(validateJsonAndGetSize(handle.proxy)).toStrictEqual([false, 0]); + expect(validateJsonAndGetSize([[[handle.proxy]]])).toStrictEqual([ false, 0, ]); @@ -449,67 +442,64 @@ describe('json', () => { }, }); - expect(getJsonSerializableInfo(arrayProxy, true)).toStrictEqual([ - true, - 0, - ]); + expect(validateJsonAndGetSize(arrayProxy, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo([[arrayProxy]], true)).toStrictEqual([ + expect(validateJsonAndGetSize([[arrayProxy]], true)).toStrictEqual([ true, 0, ]); const arrayProxyProxy = new Proxy(arrayProxy, {}); - expect(getJsonSerializableInfo([[arrayProxyProxy]], true)).toStrictEqual([ + expect(validateJsonAndGetSize([[arrayProxyProxy]], true)).toStrictEqual([ true, 0, ]); // Value: Boolean object // eslint-disable-next-line no-new-wrappers - expect(getJsonSerializableInfo(new Boolean(true), true)).toStrictEqual([ + expect(validateJsonAndGetSize(new Boolean(true), true)).toStrictEqual([ true, 0, ]); expect( // eslint-disable-next-line no-new-wrappers - getJsonSerializableInfo({ key: new Boolean(false) }, true), + validateJsonAndGetSize({ key: new Boolean(false) }, true), ).toStrictEqual([true, 0]); expect( // eslint-disable-next-line no-new-wrappers - getJsonSerializableInfo(new Boolean(false)), + validateJsonAndGetSize(new Boolean(false)), ).toStrictEqual([true, 5]); expect( // eslint-disable-next-line no-new-wrappers - getJsonSerializableInfo(new Boolean(true)), + validateJsonAndGetSize(new Boolean(true)), ).toStrictEqual([true, 4]); // Value: number negative zero - expect(getJsonSerializableInfo(-0, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(['-0', 0, -0], true)).toStrictEqual([ + expect(validateJsonAndGetSize(-0, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(['-0', 0, -0], true)).toStrictEqual([ true, 0, ]); - expect(getJsonSerializableInfo({ key: -0 }, true)).toStrictEqual([ + expect(validateJsonAndGetSize({ key: -0 }, true)).toStrictEqual([ true, 0, ]); // Value: number non finite - expect(getJsonSerializableInfo(Infinity, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo({ key: -Infinity }, true)).toStrictEqual([ + expect(validateJsonAndGetSize(Infinity, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize({ key: -Infinity }, true)).toStrictEqual([ true, 0, ]); - expect(getJsonSerializableInfo([NaN], true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize([NaN], true)).toStrictEqual([true, 0]); // Value: object abrupt expect( - getJsonSerializableInfo( + validateJsonAndGetSize( { get key() { throw new Error(); @@ -521,16 +511,13 @@ describe('json', () => { // Value: Number object // eslint-disable-next-line no-new-wrappers - expect(getJsonSerializableInfo(new Number(3.14), true)).toStrictEqual([ + expect(validateJsonAndGetSize(new Number(3.14), true)).toStrictEqual([ true, 0, ]); // eslint-disable-next-line no-new-wrappers - expect(getJsonSerializableInfo(new Number(3.14))).toStrictEqual([ - true, - 4, - ]); + expect(validateJsonAndGetSize(new Number(3.14))).toStrictEqual([true, 4]); // Value: object proxy const objectProxy = new Proxy( @@ -553,33 +540,33 @@ describe('json', () => { }, ); - expect(getJsonSerializableInfo(objectProxy, true)).toStrictEqual([ + expect(validateJsonAndGetSize(objectProxy, true)).toStrictEqual([ true, 0, ]); expect( - getJsonSerializableInfo({ l1: { l2: objectProxy } }, true), + validateJsonAndGetSize({ l1: { l2: objectProxy } }, true), ).toStrictEqual([true, 0]); // Value: object proxy revoked const handleForObjectProxy = Proxy.revocable({}, {}); handleForObjectProxy.revoke(); expect( - getJsonSerializableInfo(handleForObjectProxy.proxy, true), + validateJsonAndGetSize(handleForObjectProxy.proxy, true), ).toStrictEqual([false, 0]); expect( - getJsonSerializableInfo({ a: { b: handleForObjectProxy.proxy } }, true), + validateJsonAndGetSize({ a: { b: handleForObjectProxy.proxy } }, true), ).toStrictEqual([false, 0]); // Value: primitive top level - expect(getJsonSerializableInfo(null, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(true, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(false, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo('str', true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(123, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(undefined, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(null, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(true, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(false, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize('str', true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(123, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(undefined, true)).toStrictEqual([true, 0]); // Value: string escape ASCII const charToJson = { @@ -624,23 +611,20 @@ describe('json', () => { const jsonChars = Object.values(charToJson).join(''); const jsonCharsReversed = Object.values(charToJson).reverse().join(''); - expect(getJsonSerializableInfo(charToJson, true)).toStrictEqual([ - true, - 0, - ]); + expect(validateJsonAndGetSize(charToJson, true)).toStrictEqual([true, 0]); // eslint-disable-next-line guard-for-in for (const char in charToJson) { - expect(getJsonSerializableInfo(char, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(char, true)).toStrictEqual([true, 0]); } - expect(getJsonSerializableInfo(chars, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(charsReversed, true)).toStrictEqual([ + expect(validateJsonAndGetSize(chars, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(charsReversed, true)).toStrictEqual([ true, 0, ]); - expect(getJsonSerializableInfo(jsonChars, true)).toStrictEqual([true, 0]); - expect(getJsonSerializableInfo(jsonCharsReversed, true)).toStrictEqual([ + expect(validateJsonAndGetSize(jsonChars, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(jsonCharsReversed, true)).toStrictEqual([ true, 0, ]); @@ -663,7 +647,7 @@ describe('json', () => { // eslint-disable-next-line guard-for-in for (const strUnicode in stringEscapeUnicode) { - expect(getJsonSerializableInfo(strUnicode, true)).toStrictEqual([ + expect(validateJsonAndGetSize(strUnicode, true)).toStrictEqual([ true, 0, ]); @@ -671,107 +655,32 @@ describe('json', () => { // Value: string object // eslint-disable-next-line no-new-wrappers - expect(getJsonSerializableInfo(new String('str'), true)).toStrictEqual([ + expect(validateJsonAndGetSize(new String('str'), true)).toStrictEqual([ true, 0, ]); // eslint-disable-next-line no-new-wrappers - expect(getJsonSerializableInfo(new String('str'))).toStrictEqual([ + expect(validateJsonAndGetSize(new String('str'))).toStrictEqual([ true, 5, ]); // Value: toJSON not a function - expect(getJsonSerializableInfo({ toJSON: null }, true)).toStrictEqual([ + expect(validateJsonAndGetSize({ toJSON: null }, true)).toStrictEqual([ true, 0, ]); - expect(getJsonSerializableInfo({ toJSON: false }, true)).toStrictEqual([ + expect(validateJsonAndGetSize({ toJSON: false }, true)).toStrictEqual([ true, 0, ]); - expect(getJsonSerializableInfo({ toJSON: [] }, true)).toStrictEqual([ + expect(validateJsonAndGetSize({ toJSON: [] }, true)).toStrictEqual([ true, 0, ]); - }); - }); - - describe('isContainingCircularStructure', () => { - it('should return false if value passed is not an object', () => { - expect( - isObjectContainingCircularStructure('valueThatIsNotAnObject'), - ).toBe(false); - }); - - it('should return false for an object that does not contain any circular references', () => { - expect(isObjectContainingCircularStructure(complexObject)).toBe(false); - }); - - it('should return true for an object that contains circular references', () => { - const circularStructure = { - value: {}, - }; - circularStructure.value = circularStructure; - expect(isObjectContainingCircularStructure(circularStructure)).toBe(true); - }); - - it('should return true for an array that contains circular references', () => { - const circularArray: unknown[] = []; - circularArray[0] = circularArray; - expect(isObjectContainingCircularStructure(circularArray)).toBe(true); - }); - - it('should return true for an object that contains nested circular references', () => { - const circularStructure = { - levelOne: { - levelTwo: { - levelThree: { - levelFour: { - levelFive: {}, - }, - }, - }, - }, - }; - circularStructure.levelOne.levelTwo.levelThree.levelFour.levelFive = circularStructure; - expect(isObjectContainingCircularStructure(circularStructure)).toBe(true); - }); - - it('should return true for an object that contains multiple nested circular references', () => { - const circularStructure = { - levelOne: { - levelTwo: { - levelThree: { - levelFour: { - levelFive: {}, - }, - }, - }, - }, - anotherOne: {}, - justAnotherOne: { - toAnotherOne: { - andAnotherOne: {}, - }, - }, - }; - circularStructure.levelOne.levelTwo.levelThree.levelFour.levelFive = circularStructure; - circularStructure.anotherOne = circularStructure; - circularStructure.justAnotherOne.toAnotherOne.andAnotherOne = circularStructure; - expect(isObjectContainingCircularStructure(circularStructure)).toBe(true); - }); - }); - - describe('validateJsonAndGetSize', () => { - it('should return false for validity when circular objects are passed to the function', () => { - // This test will perform a series of validation assertions. - // These tests are taken from ECMA TC39 (test262) test scenarios used - // for testing the JSON.stringify function. - // https://github.com/tc39/test262/tree/main/test/built-ins/JSON/stringify // Value: array circular const direct: unknown[] = []; @@ -788,7 +697,7 @@ describe('json', () => { const directObject = { prop: {} }; directObject.prop = directObject; - expect(getJsonSerializableInfo(directObject, false)).toStrictEqual([ + expect(validateJsonAndGetSize(directObject, false)).toStrictEqual([ false, 0, ]); @@ -803,7 +712,7 @@ describe('json', () => { }, }; - expect(getJsonSerializableInfo(indirectObject, false)).toStrictEqual([ + expect(validateJsonAndGetSize(indirectObject, false)).toStrictEqual([ false, 0, ]); @@ -823,8 +732,50 @@ describe('json', () => { expect(validateJsonAndGetSize(circular, true)).toStrictEqual([false, 0]); }); - it('should return true for serialization and 1259 for a size of a complex nested object', () => { - expect(validateJsonAndGetSize(complexObject)).toStrictEqual([true, 1259]); + it('should return false for validation for an object that contains nested circular references', () => { + const circularStructure = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: {}, + }, + }, + }, + }, + }; + circularStructure.levelOne.levelTwo.levelThree.levelFour.levelFive = circularStructure; + expect(validateJsonAndGetSize(circularStructure, false)).toStrictEqual([ + false, + 0, + ]); + }); + + it('should return false for an object that contains multiple nested circular references', () => { + const circularStructure = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: {}, + }, + }, + }, + }, + anotherOne: {}, + justAnotherOne: { + toAnotherOne: { + andAnotherOne: {}, + }, + }, + }; + circularStructure.levelOne.levelTwo.levelThree.levelFour.levelFive = circularStructure; + circularStructure.anotherOne = circularStructure; + circularStructure.justAnotherOne.toAnotherOne.andAnotherOne = circularStructure; + expect(validateJsonAndGetSize(circularStructure)).toStrictEqual([ + false, + 0, + ]); }); }); }); diff --git a/src/json.ts b/src/json.ts index f83786ad..9ddc9eac 100644 --- a/src/json.ts +++ b/src/json.ts @@ -277,204 +277,161 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { * Checks whether a value is JSON serializable and counts the total number * of bytes needed to store the serialized version of the value. * - * Important note: This function will not check for circular references. - * For circular reference check support, use validateJsonAndGetSize function. - * - * This function assumes the encoding of the JSON is done in UTF-8. - * - * @param value - Potential JSON serializable value. - * @param skipSizing - Skip JSON size calculation (default: false). + * @param jsObject - Potential JSON serializable object. + * @param skipSizingProcess - Skip JSON size calculation (default: false). * @returns Tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether * the value was serializable and a number of bytes that it will use when serialized to JSON. */ -export function getJsonSerializableInfo( - value: unknown, - skipSizing = false, +export function validateJsonAndGetSize( + jsObject: unknown, + skipSizingProcess = false, ): [isValid: boolean, plainTextSizeInBytes: number] { - if (value === undefined) { - // Return zero for undefined, since these are omitted from JSON serialization - return [true, 0]; - } else if (value === null) { - // Return already specified constant size for null (special object) - return [true, skipSizing ? 0 : JsonSize.Null]; - } - - // Check and calculate sizes for basic types - // eslint-disable-next-line default-case - switch (typeof value) { - case 'function': - return [false, 0]; - case 'string': - return [ - true, - skipSizing ? 0 : calculateStringSize(value) + JsonSize.Quote * 2, - ]; - case 'boolean': - if (skipSizing) { - return [true, 0]; - } - return [true, value ? JsonSize.True : JsonSize.False]; - case 'number': - return [true, skipSizing ? 0 : calculateNumberSize(value)]; - } - - // Handle specific complex objects that can be serialized properly - try { - if (value instanceof Date) { - if (skipSizing) { - return [true, 0]; - } - const jsonSerializedDate = value.toJSON(); - return [ - true, - // Note: Invalid dates will serialize to null - jsonSerializedDate === null - ? JsonSize.Null - : calculateStringSize(jsonSerializedDate) + JsonSize.Quote * 2, - ]; - } else if (value instanceof Boolean) { - if (skipSizing) { - return [true, 0]; - } - // eslint-disable-next-line eqeqeq - return [true, value == true ? JsonSize.True : JsonSize.False]; - } else if (value instanceof Number) { - if (skipSizing) { - return [true, 0]; - } - return [true, value.toString().length]; - } else if (value instanceof String) { - if (skipSizing) { - return [true, 0]; - } - return [true, calculateStringSize(value.toString()) + JsonSize.Quote * 2]; - } - } catch (_) { - return [false, 0]; - } - - // If object is not plain and cannot be serialized properly, - // stop here and return false for serialization - if (!isPlainObject(value) && !Array.isArray(value)) { - return [false, 0]; - } - - // Continue object decomposition - try { - return [ - true, - Object.entries(value).reduce( - (sum, [key, nestedValue], idx, arr) => { - // Recursively process next nested object or primitive type - // eslint-disable-next-line prefer-const - let [valid, size] = getJsonSerializableInfo(nestedValue, skipSizing); - if (!valid) { - throw new Error( - 'JSON validation did not pass. Validation process stopped.', - ); - } - - if (skipSizing) { - return 0; - } - - // If the size is 0, the value is undefined and undefined in an array - // when serialized will be replaced with null - if (size === 0 && Array.isArray(value)) { - size = JsonSize.Null; - } - - // If the size is 0, that means the object is undefined and - // the rest of the object structure will be omitted - if (size === 0) { - return sum; - } - - // Objects will have be serialized with "key": value, - // therefore we include the key in the calculation here - const keySize = Array.isArray(value) - ? 0 - : key.length + JsonSize.Comma + JsonSize.Colon * 2; - - const separator = idx < arr.length - 1 ? JsonSize.Comma : 0; - - return sum + keySize + size + separator; - }, - // Starts at 2 because the serialized JSON string data (plain text) - // will minimally contain {}/[] - skipSizing ? 0 : JsonSize.Wrapper * 2, - ), - ]; - } catch (_) { - return [false, 0]; - } -} - -/** - * Check for circular structures (e.g. object referencing itself). - * - * @param objectToBeChecked - Object that potentially can have circular references. - * @returns True if circular structure is detected, false otherwise. - */ -export function isObjectContainingCircularStructure( - objectToBeChecked: unknown, -): boolean { - if (typeof objectToBeChecked !== 'object') { - return false; - } const seenObjects: unknown[] = []; - /** - * Internal function for detection of circular structures. + * Checks whether a value is JSON serializable and counts the total number + * of bytes needed to store the serialized version of the value. + * + * This function assumes the encoding of the JSON is done in UTF-8. * - * @param obj - Object that needs to be checked. - * @returns True if circular structure is detected, false otherwise. + * @param value - Potential JSON serializable value. + * @param skipSizing - Skip JSON size calculation (default: false). + * @returns Tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether + * the value was serializable and a number of bytes that it will use when serialized to JSON. */ - function detect(obj: unknown): boolean { - if (obj && typeof obj === 'object') { - if (seenObjects.indexOf(obj) !== -1) { - return true; + function getJsonSerializableInfo( + value: unknown, + skipSizing: boolean, + ): [isValid: boolean, plainTextSizeInBytes: number] { + if (value === undefined) { + // Return zero for undefined, since these are omitted from JSON serialization + return [true, 0]; + } else if (value === null) { + // Return already specified constant size for null (special object) + return [true, skipSizing ? 0 : JsonSize.Null]; + } + + // Check and calculate sizes for basic types + // eslint-disable-next-line default-case + switch (typeof value) { + case 'function': + return [false, 0]; + case 'string': + return [ + true, + skipSizing ? 0 : calculateStringSize(value) + JsonSize.Quote * 2, + ]; + case 'boolean': + if (skipSizing) { + return [true, 0]; + } + return [true, value ? JsonSize.True : JsonSize.False]; + case 'number': + return [true, skipSizing ? 0 : calculateNumberSize(value)]; + } + + // Handle specific complex objects that can be serialized properly + try { + if (value instanceof Date) { + if (skipSizing) { + return [true, 0]; + } + const jsonSerializedDate = value.toJSON(); + return [ + true, + // Note: Invalid dates will serialize to null + jsonSerializedDate === null + ? JsonSize.Null + : calculateStringSize(jsonSerializedDate) + JsonSize.Quote * 2, + ]; + } else if (value instanceof Boolean) { + if (skipSizing) { + return [true, 0]; + } + // eslint-disable-next-line eqeqeq + return [true, value == true ? JsonSize.True : JsonSize.False]; + } else if (value instanceof Number) { + if (skipSizing) { + return [true, 0]; + } + return [true, value.toString().length]; + } else if (value instanceof String) { + if (skipSizing) { + return [true, 0]; + } + return [ + true, + calculateStringSize(value.toString()) + JsonSize.Quote * 2, + ]; } - seenObjects.push(obj); - return Boolean( - Object.keys(obj).reduce((result, key) => { - if (result) { - return true; - } - - if (typeof obj[key as keyof unknown] !== 'object') { - return false; - } - - return detect(obj[key as keyof unknown]); - }, false), - ); + } catch (_) { + return [false, 0]; } - return false; - } - return detect(objectToBeChecked); -} + // If object is not plain and cannot be serialized properly, + // stop here and return false for serialization + if (!isPlainObject(value) && !Array.isArray(value)) { + return [false, 0]; + } -/** - * Checks whether a value is JSON serializable and counts the total number - * of bytes needed to store the serialized version of the value. - * - * Note: This is a wrapper function that will do check for circular structures. - * This function assumes the encoding of the JSON is done in UTF-8. - * - * @param value - Potential JSON serializable value. - * @param skipSizing - Skip JSON size calculation (default: false). - * @returns Tuple [isValid, plainTextSizeInBytes] containing a boolean that signals whether - * the value was serializable and a number of bytes that it will use when serialized to JSON. - */ -export function validateJsonAndGetSize( - value: unknown, - skipSizing = false, -): [isValid: boolean, plainTextSizeInBytes: number] { - if (isObjectContainingCircularStructure(value)) { - return [false, 0]; + // Handle circular objects + if (seenObjects.indexOf(value) !== -1) { + return [false, 0]; + } + seenObjects.push(value); + + // Continue object decomposition + try { + return [ + true, + Object.entries(value).reduce( + (sum, [key, nestedValue], idx, arr) => { + // Recursively process next nested object or primitive type + // eslint-disable-next-line prefer-const + let [valid, size] = getJsonSerializableInfo( + nestedValue, + skipSizing, + ); + if (!valid) { + throw new Error( + 'JSON validation did not pass. Validation process stopped.', + ); + } + + if (skipSizing) { + return 0; + } + + // If the size is 0, the value is undefined and undefined in an array + // when serialized will be replaced with null + if (size === 0 && Array.isArray(value)) { + size = JsonSize.Null; + } + + // If the size is 0, that means the object is undefined and + // the rest of the object structure will be omitted + if (size === 0) { + return sum; + } + + // Objects will have be serialized with "key": value, + // therefore we include the key in the calculation here + const keySize = Array.isArray(value) + ? 0 + : key.length + JsonSize.Comma + JsonSize.Colon * 2; + + const separator = idx < arr.length - 1 ? JsonSize.Comma : 0; + + return sum + keySize + size + separator; + }, + // Starts at 2 because the serialized JSON string data (plain text) + // will minimally contain {}/[] + skipSizing ? 0 : JsonSize.Wrapper * 2, + ), + ]; + } catch (_) { + return [false, 0]; + } } - return getJsonSerializableInfo(value, skipSizing); + return getJsonSerializableInfo(jsObject, skipSizingProcess); } From 484a376f6f19a24fae5cb7bd37a434fab9ae54cf Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 11 Jul 2022 16:54:19 +0200 Subject: [PATCH 30/33] Improve circular object detection --- src/json.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/json.ts | 8 ++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 4717a9ef..40906e90 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -777,5 +777,42 @@ describe('json', () => { 0, ]); }); + + it('should return true for validity for an object that contains same object multiple times', () => { + const date = new Date(); + const testObject = { + value: 'whatever', + }; + const objectToTest = { + a: date, + b: date, + c: date, + testOne: testObject, + testTwo: testObject, + testThree: { + nestedObjectTest: { + multipleTimes: { + valueOne: testObject, + valueTwo: testObject, + valueThree: testObject, + valueFour: testObject, + valueFive: date, + valueSix: {}, + }, + }, + }, + testFour: {}, + testFive: { + something: null, + somethingElse: null, + anotherValue: null, + }, + }; + + expect(validateJsonAndGetSize(objectToTest, true)).toStrictEqual([ + true, + 0, + ]); + }); }); }); diff --git a/src/json.ts b/src/json.ts index 9ddc9eac..768c0732 100644 --- a/src/json.ts +++ b/src/json.ts @@ -286,7 +286,7 @@ export function validateJsonAndGetSize( jsObject: unknown, skipSizingProcess = false, ): [isValid: boolean, plainTextSizeInBytes: number] { - const seenObjects: unknown[] = []; + const seenObjects = new Set(); /** * Checks whether a value is JSON serializable and counts the total number * of bytes needed to store the serialized version of the value. @@ -374,10 +374,10 @@ export function validateJsonAndGetSize( } // Handle circular objects - if (seenObjects.indexOf(value) !== -1) { + if (seenObjects.has(value)) { return [false, 0]; } - seenObjects.push(value); + seenObjects.add(value); // Continue object decomposition try { @@ -396,7 +396,7 @@ export function validateJsonAndGetSize( 'JSON validation did not pass. Validation process stopped.', ); } - + seenObjects.delete(value); if (skipSizing) { return 0; } From 50f41c2b7851ab8ea4c7ebaae6e64758e9faf303 Mon Sep 17 00:00:00 2001 From: david0xd Date: Mon, 11 Jul 2022 17:13:13 +0200 Subject: [PATCH 31/33] Improve Date object sizing --- src/json.ts | 5 ++--- src/misc.ts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/json.ts b/src/json.ts index 768c0732..2fb234a4 100644 --- a/src/json.ts +++ b/src/json.ts @@ -335,13 +335,12 @@ export function validateJsonAndGetSize( if (skipSizing) { return [true, 0]; } - const jsonSerializedDate = value.toJSON(); return [ true, // Note: Invalid dates will serialize to null - jsonSerializedDate === null + isNaN(value.getDate()) ? JsonSize.Null - : calculateStringSize(jsonSerializedDate) + JsonSize.Quote * 2, + : JsonSize.Date + JsonSize.Quote * 2, ]; } else if (value instanceof Boolean) { if (skipSizing) { diff --git a/src/misc.ts b/src/misc.ts index 2938d58c..22064a25 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -106,6 +106,7 @@ export enum JsonSize { False = 5, Quote = 1, Colon = 1, + Date = 24, } /** From eb20c5378fa584b83b00dee70685748f2790f43a Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 12 Jul 2022 13:20:42 +0200 Subject: [PATCH 32/33] Change type-check approach (if-else) --- src/json.ts | 49 ++++++++++++++++--------------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/json.ts b/src/json.ts index 2fb234a4..a865f62f 100644 --- a/src/json.ts +++ b/src/json.ts @@ -310,56 +310,39 @@ export function validateJsonAndGetSize( return [true, skipSizing ? 0 : JsonSize.Null]; } - // Check and calculate sizes for basic types - // eslint-disable-next-line default-case - switch (typeof value) { - case 'function': - return [false, 0]; - case 'string': - return [ - true, - skipSizing ? 0 : calculateStringSize(value) + JsonSize.Quote * 2, - ]; - case 'boolean': - if (skipSizing) { - return [true, 0]; - } - return [true, value ? JsonSize.True : JsonSize.False]; - case 'number': - return [true, skipSizing ? 0 : calculateNumberSize(value)]; - } - - // Handle specific complex objects that can be serialized properly + // Check and calculate sizes for basic (and some special) types + const typeOfValue = typeof value; try { - if (value instanceof Date) { - if (skipSizing) { - return [true, 0]; - } + if (typeOfValue === 'function') { + return [false, 0]; + } else if (typeOfValue === 'string' || value instanceof String) { return [ true, - // Note: Invalid dates will serialize to null - isNaN(value.getDate()) - ? JsonSize.Null - : JsonSize.Date + JsonSize.Quote * 2, + skipSizing + ? 0 + : calculateStringSize(value as string) + JsonSize.Quote * 2, ]; - } else if (value instanceof Boolean) { + } else if (typeOfValue === 'boolean' || value instanceof Boolean) { if (skipSizing) { return [true, 0]; } // eslint-disable-next-line eqeqeq return [true, value == true ? JsonSize.True : JsonSize.False]; - } else if (value instanceof Number) { + } else if (typeOfValue === 'number' || value instanceof Number) { if (skipSizing) { return [true, 0]; } - return [true, value.toString().length]; - } else if (value instanceof String) { + return [true, calculateNumberSize(value as number)]; + } else if (value instanceof Date) { if (skipSizing) { return [true, 0]; } return [ true, - calculateStringSize(value.toString()) + JsonSize.Quote * 2, + // Note: Invalid dates will serialize to null + isNaN(value.getDate()) + ? JsonSize.Null + : JsonSize.Date + JsonSize.Quote * 2, ]; } } catch (_) { From b6482a75ffec33875f53b172e061dd6259fc3d6f Mon Sep 17 00:00:00 2001 From: david0xd Date: Tue, 12 Jul 2022 15:18:01 +0200 Subject: [PATCH 33/33] Add explanation comments for circular structure detection and improve tests --- src/json.test.ts | 11 ++++++++++- src/json.ts | 10 +++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 40906e90..fd679b3d 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -778,7 +778,8 @@ describe('json', () => { ]); }); - it('should return true for validity for an object that contains same object multiple times', () => { + it('should return true for validity for an object that contains the same object multiple times', () => { + // This will test if false positives are removed from the circular reference detection const date = new Date(); const testObject = { value: 'whatever', @@ -806,6 +807,14 @@ describe('json', () => { something: null, somethingElse: null, anotherValue: null, + somethingAgain: testObject, + anotherOne: { + nested: { + multipleTimes: { + valueOne: testObject, + }, + }, + }, }, }; diff --git a/src/json.ts b/src/json.ts index a865f62f..6f1f21cf 100644 --- a/src/json.ts +++ b/src/json.ts @@ -355,10 +355,13 @@ export function validateJsonAndGetSize( return [false, 0]; } - // Handle circular objects + // Circular object detection (handling) + // Check if the same object already exists if (seenObjects.has(value)) { return [false, 0]; } + // Add new object to the seen objects set + // Only the plain objects should be added (Primitive types are skipped) seenObjects.add(value); // Continue object decomposition @@ -378,7 +381,12 @@ export function validateJsonAndGetSize( 'JSON validation did not pass. Validation process stopped.', ); } + + // Circular object detection + // Once a child node is visited and processed remove it from the set. + // This will prevent false positives with the same adjacent objects. seenObjects.delete(value); + if (skipSizing) { return 0; }