diff --git a/src/json.test.ts b/src/json.test.ts index a81627075..fd679b3dd 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, @@ -11,6 +18,7 @@ import { isValidJson, jsonrpc2, JsonRpcError, + validateJsonAndGetSize, } from '.'; const getError = () => { @@ -286,4 +294,534 @@ describe('json', () => { ).not.toThrow(); }); }); + + describe('validateJsonAndGetSize', () => { + it('should return true for serialization and 10 for a size', () => { + const valueToSerialize = { + a: 'bc', + }; + + expect(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([ + true, + 10, + ]); + }); + + it('should return true for serialization and 11 for a size', () => { + const valueToSerialize = { + a: 1234, + }; + + expect(validateJsonAndGetSize(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', + }; + + expect(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([ + true, + 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(validateJsonAndGetSize(valueToSerialize)).toStrictEqual([true, 2]); + }); + + it('should return true for serialization and 25 for a size when some of the values are undefined', () => { + expect( + 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(validateJsonAndGetSize(arrayOfMixedSpecialObjects)).toStrictEqual([ + true, + 51, + ]); + }); + + it('should return true for serialization and 73 for a size, for an array of numbers', () => { + expect( + validateJsonAndGetSize(arrayOfDifferentKindsOfNumbers), + ).toStrictEqual([true, 73]); + }); + + 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 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(validateJsonAndGetSize(dateObjects)).toStrictEqual([true, 107]); + }); + + 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( + validateJsonAndGetSize(nonSerializableNestedObject), + ).toStrictEqual([false, 0]); + }); + + it('should return true for serialization and 0 for a size when sizing is skipped', () => { + expect(validateJsonAndGetSize(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( + validateJsonAndGetSize(nonSerializableNestedObject, 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'), + }; + expect(validateJsonAndGetSize(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(validateJsonAndGetSize(objectContainingFunction)).toStrictEqual([ + false, + 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 proxy revoked + const handle = Proxy.revocable([], {}); + handle.revoke(); + + expect(validateJsonAndGetSize(handle.proxy)).toStrictEqual([false, 0]); + expect(validateJsonAndGetSize([[[handle.proxy]]])).toStrictEqual([ + false, + 0, + ]); + + // Value: array proxy + const arrayProxy = new Proxy([], { + get(_target, key) { + if (key === 'length') { + return 2; + } + return Number(key); + }, + }); + + expect(validateJsonAndGetSize(arrayProxy, true)).toStrictEqual([true, 0]); + + expect(validateJsonAndGetSize([[arrayProxy]], true)).toStrictEqual([ + true, + 0, + ]); + + const arrayProxyProxy = new Proxy(arrayProxy, {}); + expect(validateJsonAndGetSize([[arrayProxyProxy]], true)).toStrictEqual([ + true, + 0, + ]); + + // Value: Boolean object + // eslint-disable-next-line no-new-wrappers + expect(validateJsonAndGetSize(new Boolean(true), true)).toStrictEqual([ + true, + 0, + ]); + + expect( + // eslint-disable-next-line no-new-wrappers + validateJsonAndGetSize({ key: new Boolean(false) }, true), + ).toStrictEqual([true, 0]); + + expect( + // eslint-disable-next-line no-new-wrappers + validateJsonAndGetSize(new Boolean(false)), + ).toStrictEqual([true, 5]); + + expect( + // eslint-disable-next-line no-new-wrappers + validateJsonAndGetSize(new Boolean(true)), + ).toStrictEqual([true, 4]); + + // Value: number negative zero + expect(validateJsonAndGetSize(-0, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(['-0', 0, -0], true)).toStrictEqual([ + true, + 0, + ]); + + expect(validateJsonAndGetSize({ key: -0 }, true)).toStrictEqual([ + true, + 0, + ]); + + // Value: number non finite + expect(validateJsonAndGetSize(Infinity, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize({ key: -Infinity }, true)).toStrictEqual([ + true, + 0, + ]); + expect(validateJsonAndGetSize([NaN], true)).toStrictEqual([true, 0]); + + // Value: object abrupt + expect( + validateJsonAndGetSize( + { + get key() { + throw new Error(); + }, + }, + true, + ), + ).toStrictEqual([false, 0]); + + // Value: Number object + // eslint-disable-next-line no-new-wrappers + expect(validateJsonAndGetSize(new Number(3.14), true)).toStrictEqual([ + true, + 0, + ]); + + // eslint-disable-next-line no-new-wrappers + expect(validateJsonAndGetSize(new Number(3.14))).toStrictEqual([true, 4]); + + // 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(validateJsonAndGetSize(objectProxy, true)).toStrictEqual([ + true, + 0, + ]); + + expect( + validateJsonAndGetSize({ l1: { l2: objectProxy } }, true), + ).toStrictEqual([true, 0]); + + // Value: object proxy revoked + const handleForObjectProxy = Proxy.revocable({}, {}); + handleForObjectProxy.revoke(); + expect( + validateJsonAndGetSize(handleForObjectProxy.proxy, true), + ).toStrictEqual([false, 0]); + + expect( + validateJsonAndGetSize({ a: { b: handleForObjectProxy.proxy } }, true), + ).toStrictEqual([false, 0]); + + // Value: primitive top level + 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 = { + '"': '\\"', + '\\': '\\\\', + '\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(validateJsonAndGetSize(charToJson, true)).toStrictEqual([true, 0]); + + // eslint-disable-next-line guard-for-in + for (const char in charToJson) { + expect(validateJsonAndGetSize(char, true)).toStrictEqual([true, 0]); + } + + expect(validateJsonAndGetSize(chars, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(charsReversed, true)).toStrictEqual([ + true, + 0, + ]); + expect(validateJsonAndGetSize(jsonChars, true)).toStrictEqual([true, 0]); + expect(validateJsonAndGetSize(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(validateJsonAndGetSize(strUnicode, true)).toStrictEqual([ + true, + 0, + ]); + } + + // Value: string object + // eslint-disable-next-line no-new-wrappers + expect(validateJsonAndGetSize(new String('str'), true)).toStrictEqual([ + true, + 0, + ]); + + // eslint-disable-next-line no-new-wrappers + expect(validateJsonAndGetSize(new String('str'))).toStrictEqual([ + true, + 5, + ]); + + // Value: toJSON not a function + expect(validateJsonAndGetSize({ toJSON: null }, true)).toStrictEqual([ + true, + 0, + ]); + + expect(validateJsonAndGetSize({ toJSON: false }, true)).toStrictEqual([ + true, + 0, + ]); + + expect(validateJsonAndGetSize({ toJSON: [] }, true)).toStrictEqual([ + true, + 0, + ]); + + // 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(validateJsonAndGetSize(directObject, false)).toStrictEqual([ + false, + 0, + ]); + + const indirectObject = { + p1: { + p2: { + get p3() { + return indirectObject; + }, + }, + }, + }; + + expect(validateJsonAndGetSize(indirectObject, false)).toStrictEqual([ + false, + 0, + ]); + + // Value: toJSON object circular + const obj = { + toJSON() { + return {}; + }, + }; + const circular = { prop: obj }; + + obj.toJSON = function () { + return circular; + }; + + expect(validateJsonAndGetSize(circular, true)).toStrictEqual([false, 0]); + }); + + 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, + ]); + }); + + 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', + }; + 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, + somethingAgain: testObject, + anotherOne: { + nested: { + multipleTimes: { + valueOne: testObject, + }, + }, + }, + }, + }; + + expect(validateJsonAndGetSize(objectToTest, true)).toStrictEqual([ + true, + 0, + ]); + }); + }); }); diff --git a/src/json.ts b/src/json.ts index 7ab35f9bc..6f1f21cf3 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. @@ -266,3 +272,156 @@ export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { }; return isValidJsonRpcId; } + +/** + * Checks whether a value is JSON serializable and counts the total number + * of bytes needed to store the serialized version of the value. + * + * @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 validateJsonAndGetSize( + jsObject: unknown, + skipSizingProcess = false, +): [isValid: boolean, plainTextSizeInBytes: number] { + 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. + * + * 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. + */ + 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 (and some special) types + const typeOfValue = typeof value; + try { + if (typeOfValue === 'function') { + return [false, 0]; + } else if (typeOfValue === 'string' || value instanceof String) { + return [ + true, + skipSizing + ? 0 + : calculateStringSize(value as string) + JsonSize.Quote * 2, + ]; + } 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 (typeOfValue === 'number' || value instanceof Number) { + if (skipSizing) { + return [true, 0]; + } + return [true, calculateNumberSize(value as number)]; + } else if (value instanceof Date) { + if (skipSizing) { + return [true, 0]; + } + return [ + true, + // Note: Invalid dates will serialize to null + isNaN(value.getDate()) + ? JsonSize.Null + : JsonSize.Date + 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]; + } + + // 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 + 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.', + ); + } + + // 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; + } + + // 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(jsObject, skipSizingProcess); +} diff --git a/src/misc.test.ts b/src/misc.test.ts index a380ef80c..435544506 100644 --- a/src/misc.test.ts +++ b/src/misc.test.ts @@ -4,6 +4,10 @@ import { isObject, hasProperty, RuntimeObject, + isPlainObject, + calculateNumberSize, + isASCII, + calculateStringSize, } from '.'; describe('miscellaneous', () => { @@ -108,4 +112,113 @@ 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); + }); + + 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', () => { + 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('calculateStringSize', () => { + it('should return 96 for a size of ASCII string', () => { + const str = + '!"#$%&\'()*+,-./0123456789:;<=>?' + + '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]' + + '^_`abcdefghijklmnopqrstuvwxyz{|}~'; + expect(calculateStringSize(str)).toBe(96); + }); + + 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); + }); + + it('should return 10 for a size of special characters', () => { + const str = '"\\\n\r\t'; + expect(calculateStringSize(str)).toBe(10); + }); + }); + + 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 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', () => { + expect(calculateNumberSize(9007199254740991)).toBe(16); + }); + + 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); + }); + + 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 a1470c169..22064a251 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -92,3 +92,86 @@ 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, + Quote = 1, + Colon = 1, + Date = 24, +} + +/** + * 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. + * + * @param value - Value to be checked. + * @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) { + 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 is ASCII. + * + * @param character - Character. + * @returns True if a character code is ASCII, false if not. + */ +export function isASCII(character: string) { + return character.charCodeAt(0) <= 127; +} + +/** + * 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): number { + const size = value.split('').reduce((total, character) => { + if (isASCII(character)) { + return total + 1; + } + return total + 2; + }, 0); + + // Also detect characters that need backslash escape + return size + (value.match(ESCAPE_CHARACTERS_REGEXP) ?? []).length; +} + +/** + * 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 { + return value.toString().length; +} diff --git a/src/test.data.ts b/src/test.data.ts new file mode 100644 index 000000000..c2e8f96ba --- /dev/null +++ b/src/test.data.ts @@ -0,0 +1,133 @@ +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šđćč žЀЂЇЄЖФћΣΩδλ', + specialCharacters: '"\\\n\r\t', + stringWithSpecialEscapedCharacters: + "this\nis\nsome\"'string\r\nwith\tspecial\\escaped\tcharacters'", + }, + 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, + }, + dates: { + someDate: new Date(), + someOther: new Date(2022, 0, 2, 15, 4, 5), + invalidDate: new Date('bad-date-format'), + }, + }, + }, + }, +}; + +export const nonSerializableNestedObject = { + levelOne: { + levelTwo: { + levelThree: { + levelFour: { + levelFive: () => { + return 'anything'; + }, + }, + }, + }, + }, +}; + +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', +};