diff --git a/package.json b/package.json index 988c874849..28d35aad20 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@types/chai": "^4.3.4", "@types/chai-as-promised": "^7.1.5", "@types/mocha": "^10.0.1", - "@types/qs": "^6.9.7", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "chai": "^4.3.6", @@ -55,8 +54,7 @@ "nanoid": "^3.2.0" }, "dependencies": { - "@types/node": ">=8.1.0", - "qs": "^6.11.0" + "@types/node": ">=8.1.0" }, "license": "MIT", "scripts": { @@ -101,4 +99,4 @@ "require": "./cjs/stripe.cjs.node.js" } } -} +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 32585e46a2..453c334cee 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import * as qs from 'qs'; import { RequestData, UrlInterpolator, @@ -44,11 +43,12 @@ export function isOptionsHash(o: unknown): boolean | unknown { * (forming the conventional key 'parent[child]=value') */ export function stringifyRequestData(data: RequestData | string): string { + if (typeof data === 'string') return data; + return ( - qs - .stringify(data, { - serializeDate: (d: Date) => Math.floor(d.getTime() / 1000).toString(), - }) + stringify(data, { + serializeDate: (d: Date) => Math.floor(d.getTime() / 1000).toString(), + }) // Don't use strict form encoding by changing the square bracket control // characters back to their literals. This is fine by the server, and // makes these parameter strings easier to read. @@ -57,6 +57,40 @@ export function stringifyRequestData(data: RequestData | string): string { ); } +/** + * Stringifies an object into a query string + * @param obj - The object to stringify + * @param config - Configuration object for custom serialization rules + * @param prefix - The parent key when nesting + * @param visited - Set of previously visited objects to avoid cyclic references + * @returns The query string + */ +export function stringify( + obj: RequestData, + config = { + serializeDate: (d: Date): string | number => d.toISOString(), + }, + prefix?: string, + visited = new Set() +): string { + if (visited.has(obj)) { + // Clear visited set and throw an error if a cyclic reference is detected + visited.clear(); + throw new RangeError('Cyclic object value'); + } + + // Add the current object to the visited set + visited.add(obj); + + // Serialize the object + const result = serializeObject(obj, prefix || '', config, visited); + + // Remove the current object from the visited set + visited.delete(obj); + + return result; +} + /** * Outputs a new function with interpolated object property values. * Use like so: @@ -379,3 +413,106 @@ export function concat(arrays: Array): Uint8Array { return merged; } + +// --- start: required functions for stringify --- + +// Suggestion: +// These are internal functions that are required for the `stringify` function. +// Hence, we can make changes to only export them in 'test' environment for unit testing. + +// Custom implementation of strictUriEncode package +export function strictUriEncode(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (char) => + '%' + + char + .charCodeAt(0) + .toString(16) + .toUpperCase() + ); +} + +// Serializes a Date object based on the provided configuration +export function serializeDate( + d: Date, + config: {serializeDate: (d: Date) => string | number} +): string { + return config.serializeDate(d).toString(); +} + +// Serializes an array into a query string +export function serializeArray( + arr: unknown[], + key: string, + config: {serializeDate: (d: Date) => string | number}, + visited: Set +): string { + const str = []; + + for (let i = 0; i < arr.length; i++) { + const serializedElement = serializeElement( + key + '[' + i + ']', + arr[i], + config, + visited + ); + str.push(serializedElement); + } + + return str.join('&'); +} + +// Serializes an object into a query string +export function serializeObject( + obj: RequestData, + key: string, + config: {serializeDate: (d: Date) => string | number}, + visited: Set +): string { + const str = []; + + for (const propertyName in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, propertyName)) continue; + + const k = key ? key + '[' + propertyName + ']' : propertyName; + + const v = obj[propertyName]; + + if (v === undefined) continue; + + const serializedElement = serializeElement(k, v, config, visited); + + str.push(serializedElement); + } + + return str.join('&'); +} + +// Helper function to serialize different types of elements +export function serializeElement( + key: string, + value: unknown, + config: {serializeDate: (d: Date) => string | number}, + visited: Set +): string { + if (value === null) { + return encodeURIComponent(key) + '='; + } + + if (value instanceof Date) { + return encodeURIComponent(key) + '=' + serializeDate(value, config); + } + + if (Array.isArray(value)) { + return serializeArray(value, key, config, visited); + } + + if (typeof value === 'object' && value !== null) { + return stringify(value as RequestData, config, key, visited); + } + + return encodeURIComponent(key) + '=' + strictUriEncode(String(value)); +} + +// --- end: required functions for stringify --- diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 248d8ee92b..e37c54d9f4 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -18,6 +18,314 @@ describe('utils', () => { }); }); + describe('strictUriEncode', () => { + it('should encode a string without special characters', () => { + const str = 'hello'; + const result = utils.strictUriEncode(str); + expect(result).to.equal('hello'); + }); + + it('should encode a string with spaces', () => { + const str = 'hello world'; + const result = utils.strictUriEncode(str); + expect(result).to.equal('hello%20world'); + }); + + it('should encode a string with special characters', () => { + const str = "hello!world'()*"; + const result = utils.strictUriEncode(str); + expect(result).to.equal('hello%21world%27%28%29%2A'); + }); + + it('should encode a string with mixed characters', () => { + const str = "hello world! It's a test."; + const result = utils.strictUriEncode(str); + expect(result).to.equal('hello%20world%21%20It%27s%20a%20test.'); + }); + + it('should encode an empty string', () => { + const str = ''; + const result = utils.strictUriEncode(str); + expect(result).to.equal(''); + }); + }); + + describe('serializeDate', () => { + it('should serialize a date to a string using the provided configuration', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const config = { + serializeDate: (d: Date) => d.toISOString(), + }; + + const result = utils.serializeDate(date, config); + + expect(result).to.equal('2023-01-01T00:00:00.000Z'); + }); + + it('should serialize a date to a number using the provided configuration', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const config = { + serializeDate: (d: Date) => d.getTime(), + }; + + const result = utils.serializeDate(date, config); + + expect(result).to.equal(date.getTime().toString()); + }); + + it('should handle custom serialization logic', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const config = { + serializeDate: (d: Date) => `Year: ${d.getUTCFullYear()}`, + }; + + const result = utils.serializeDate(date, config); + + expect(result).to.equal('Year: 2023'); + }); + }); + + describe('serializeArray', () => { + const serializeDate = (d: Date) => + Math.floor(d.getTime() / 1000).toString(); + const config = {serializeDate}; + + it('should serialize an empty array', () => { + const arr: unknown[] = []; + const result = utils.serializeArray(arr, 'key', config, new Set()); + expect(result).to.equal(''); + }); + + it('should serialize an array of strings', () => { + const arr = ['value1', 'value2']; + const result = utils.serializeArray(arr, 'key', config, new Set()); + // i.e key[0]=value1&key[1]=value2 + expect(result).to.equal('key%5B0%5D=value1&key%5B1%5D=value2'); + }); + + it('should serialize an array of numbers', () => { + const arr = [1, 2, 3]; + const result = utils.serializeArray(arr, 'key', config, new Set()); + // i.e key[0]=1&key[1]=2&key[2]=3 + expect(result).to.equal('key%5B0%5D=1&key%5B1%5D=2&key%5B2%5D=3'); + }); + + it('should serialize an array of dates', () => { + const arr = [ + new Date('2023-01-01T00:00:00Z'), + new Date('2023-01-02T00:00:00Z'), + ]; + const result = utils.serializeArray(arr, 'key', config, new Set()); + + expect(result).to.equal( + `key%5B0%5D=${serializeDate(arr[0] as Date)}&key%5B1%5D=${serializeDate( + arr[1] as Date + )}` + ); + }); + + it('should serialize an array with mixed types', () => { + const arr = ['value', 123, new Date('2023-01-01T00:00:00Z')]; + const result = utils.serializeArray(arr, 'key', config, new Set()); + + expect(result).to.equal( + `key%5B0%5D=value&key%5B1%5D=123&key%5B2%5D=${serializeDate( + arr[2] as Date + )}` + ); + }); + }); + + describe('serializeObject', () => { + const serializeDate = (d: Date) => + Math.floor(d.getTime() / 1000).toString(); + const config = {serializeDate}; + + it('should serialize an empty object', () => { + const obj = {}; + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal(''); + }); + + it('should serialize an object with string properties', () => { + const obj = {key1: 'value1', key2: 'value2'}; + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal('key1=value1&key2=value2'); + }); + + it('should serialize an object with number properties', () => { + const obj = {key1: 1, key2: 2}; + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal('key1=1&key2=2'); + }); + + it('should serialize an object with date properties', () => { + const obj = { + key1: new Date('2023-01-01T00:00:00Z'), + key2: new Date('2023-01-02T00:00:00Z'), + }; + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal( + `key1=${serializeDate(obj.key1)}&key2=${serializeDate(obj.key2)}` + ); + }); + + it('should serialize an object with mixed properties', () => { + const obj = { + key1: 'value', + key2: 123, + key3: new Date('2023-01-01T00:00:00Z'), + }; + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal( + `key1=value&key2=123&key3=${serializeDate(obj.key3)}` + ); + }); + + it('should serialize a nested object', () => { + const obj = { + key1: {subKey1: 'subValue1', subKey2: 'subValue2'}, + key2: 'value2', + }; + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal( + 'key1%5BsubKey1%5D=subValue1&key1%5BsubKey2%5D=subValue2&key2=value2' + ); + }); + + it('should skip properties which does not belong to object', () => { + const obj = { + key1: 'value1', + key2: 'value2', + }; + + const prototype = Object.getPrototypeOf(obj); + + prototype.newPrototypeProperty = 'newProp'; + + const result = utils.serializeObject(obj, '', config, new Set()); + + expect(result).to.equal('key1=value1&key2=value2'); + }); + + it('should skip undefined properties', () => { + const obj = {key1: undefined}; + + const result = utils.serializeObject(obj, '', config, new Set()); + expect(result).to.equal(''); + }); + }); + + describe('serializeElement', () => { + const serializeDate = (d: Date) => + Math.floor(d.getTime() / 1000).toString(); + const config = {serializeDate}; + + it('should serialize a null value', () => { + const result = utils.serializeElement('key', null, config, new Set()); + expect(result).to.equal('key='); + }); + + it('should serialize a date value', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const result = utils.serializeElement('key', date, config, new Set()); + expect(result).to.equal(`key=${serializeDate(date)}`); + }); + + it('should serialize an array value', () => { + const arr = ['value1', 'value2']; + const result = utils.serializeElement('key', arr, config, new Set()); + expect(result).to.equal('key%5B0%5D=value1&key%5B1%5D=value2'); + }); + + it('should serialize an object value', () => { + const obj = {subKey: 'subValue'}; + const result = utils.serializeElement('key', obj, config, new Set()); + expect(result).to.equal('key%5BsubKey%5D=subValue'); + }); + + it('should serialize a string value', () => { + const result = utils.serializeElement('key', 'value', config, new Set()); + expect(result).to.equal('key=value'); + }); + + it('should serialize a number value', () => { + const result = utils.serializeElement('key', 123, config, new Set()); + expect(result).to.equal('key=123'); + }); + + it('should serialize a boolean value', () => { + const result = utils.serializeElement('key', true, config, new Set()); + expect(result).to.equal('key=true'); + }); + }); + + describe('stringify', () => { + const serializeDate = (d: Date) => d.toISOString(); + const config = {serializeDate}; + + it('should stringify an empty object', () => { + const obj = {}; + const result = utils.stringify(obj, config); + expect(result).to.equal(''); + }); + + it('should stringify an object with string properties', () => { + const obj = {key1: 'value1', key2: 'value2'}; + const result = utils.stringify(obj, config); + expect(result).to.equal('key1=value1&key2=value2'); + }); + + it('should stringify an object with number properties', () => { + const obj = {key1: 1, key2: 2}; + const result = utils.stringify(obj, config); + expect(result).to.equal('key1=1&key2=2'); + }); + + it('should stringify an object with date properties', () => { + const obj = { + key1: new Date('2023-01-01T00:00:00Z'), + key2: new Date('2023-01-02T00:00:00Z'), + }; + const result = utils.stringify(obj, config); + expect(result).to.equal( + `key1=${serializeDate(obj.key1)}&key2=${serializeDate(obj.key2)}` + ); + }); + + it('should stringify an object with mixed properties', () => { + const obj = { + key1: 'value', + key2: 123, + key3: new Date('2023-01-01T00:00:00Z'), + }; + const result = utils.stringify(obj, config); + expect(result).to.equal( + `key1=value&key2=123&key3=${serializeDate(obj.key3)}` + ); + }); + + it('should stringify a nested object', () => { + const obj = { + key1: {subKey1: 'subValue1', subKey2: 'subValue2'}, + key2: 'value2', + }; + const result = utils.stringify(obj, config); + expect(result).to.equal( + 'key1%5BsubKey1%5D=subValue1&key1%5BsubKey2%5D=subValue2&key2=value2' + ); + }); + + it('should throw an error for cyclic objects', () => { + const obj: any = {key1: 'value1'}; + obj.key2 = obj; // Create cyclic reference + expect(() => utils.stringify(obj, config)).to.throw( + RangeError, + 'Cyclic object value' + ); + }); + }); + describe('extractUrlParams', () => { it('works with multiple params', () => { expect( @@ -110,6 +418,10 @@ describe('utils', () => { ].join('&') ); }); + + it('Handles string data', () => { + expect(utils.stringifyRequestData('')).to.equal(''); + }); }); describe('protoExtend', () => {