diff --git a/package.json b/package.json index 34d288637a..055df91302 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": { @@ -97,4 +95,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..eb7185b12f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,10 @@ -import * as qs from 'qs'; import { - RequestData, - UrlInterpolator, + MultipartRequestData, RequestArgs, - StripeResourceObject, + RequestData, RequestHeaders, - MultipartRequestData, + StripeResourceObject, + UrlInterpolator, } from './Types.js'; const OPTIONS_KEYS = [ @@ -39,16 +38,91 @@ export function isOptionsHash(o: unknown): boolean | unknown { ); } +const has = Function.call.bind(Object.prototype.hasOwnProperty); + +// https://github.com/sindresorhus/query-string/blob/main/base.js +const strictUriEncode = (str: string): string => + encodeURIComponent(str).replace( + /[!'()*]/g, + (x) => + `%${x + .charCodeAt(0) + .toString(16) + .toUpperCase()}` + ); + +/** + * Stringifies an Object into a query string + * @param obj - The object to stringify + * @param prefix - The parent key when nesting + * @param visited - Previously visited objects + * @returns The query string + */ +export function stringify ( + obj: Record, + config = { + serializeDate: (d: Date): string | number => d.toISOString(), + }, + prefix?: string, + visited = new Set() +): string { + const str = []; + if (visited.has(obj)) { + visited.clear(); + throw new RangeError('Cyclic object value'); + } + visited.add(obj); + for (const p in obj) { + if (has(obj, p)) { + const k = prefix ? prefix + '[' + p + ']' : p; + const v = obj[p]; + if (v === undefined) { + continue; + } else if (v instanceof Date) { + str.push(encodeURIComponent(k) + '=' + config.serializeDate(v)); + } else if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) { + // eslint-disable-next-line max-depth + if (typeof v[i] === 'object' && v[i] !== null) { + str.push( + stringify( + v[i] as Record, + config, + k + '[' + i + ']', + visited + ) + ); + } else { + str.push( + encodeURIComponent(k + '[' + i + ']') + + '=' + + strictUriEncode(v[i]) + ); + } + } + } else if (typeof v === 'object' && v !== null) { + str.push(stringify(v as Record, config, k, visited)); + } else { + str.push( + encodeURIComponent(k) + '=' + strictUriEncode((v as string) ?? '') + ); + } + } + } + visited.delete(obj); + return str.join('&'); +}; + /** * Stringifies an Object, accommodating nested objects * (forming the conventional key 'parent[child]=value') */ export function stringifyRequestData(data: RequestData | string): string { + if (typeof data === 'string') return ''; 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. diff --git a/test/resources/OAuth.spec.js b/test/resources/OAuth.spec.js index fba0499f97..fd652a1bd4 100644 --- a/test/resources/OAuth.spec.js +++ b/test/resources/OAuth.spec.js @@ -4,7 +4,7 @@ const stripe = require('../testUtils.js').getSpyableStripe(); const expect = require('chai').expect; const URL = require('url'); -const qs = require('qs'); +const qs = require('node:querystring'); describe('OAuth', () => { describe('authorize', () => { diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 248d8ee92b..a40418cb60 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,6 +1,6 @@ // @ts-nocheck +import { expect } from 'chai'; import * as utils from '../src/utils.js'; -import {expect} from 'chai'; describe('utils', () => { describe('makeURLInterpolator', () => { @@ -472,6 +472,164 @@ describe('utils', () => { expect(mergedBufToString).to.equal('foobar'); }); }); + + describe('stringify', function() { + it('stringifies a querystring object', function() { + expect(utils.stringify({a: 'b'})).to.equal('a=b'); + expect(utils.stringify({a: 1})).to.equal('a=1'); + expect(utils.stringify({a: 1, b: 2})).to.equal('a=1&b=2'); + expect(utils.stringify({a: 'A_Z'})).to.equal('a=A_Z'); + expect(utils.stringify({a: '€'})).to.equal('a=%E2%82%AC'); + expect(utils.stringify({a: ''})).to.equal('a=%EE%80%80'); + expect(utils.stringify({a: 'א'})).to.equal('a=%D7%90'); + expect(utils.stringify({a: '𐐷'})).to.equal('a=%F0%90%90%B7'); + }); + + it('stringifies falsy values', function() { + expect(utils.stringify(undefined), ''); + expect(utils.stringify(null), ''); + expect(utils.stringify(false), ''); + expect(utils.stringify(0), ''); + }); + it('encodes dot in key of object when encodeDotInKeys and allowDots is provided', function() { + expect( + utils.stringify({'name.obj': {first: 'John', last: 'Doe'}}) + ).to.equal('name.obj%5Bfirst%5D=John&name.obj%5Blast%5D=Doe'); + + expect( + utils.stringify({ + 'name.obj.subobject': {'first.godly.name': 'John', last: 'Doe'}, + }) + ).to.equal( + 'name.obj.subobject%5Bfirst.godly.name%5D=John&name.obj.subobject%5Blast%5D=Doe' + ); + }); + + it('stringifies nested falsy values', function() { + expect(utils.stringify({a: {b: {c: null}}})).to.equal('a%5Bb%5D%5Bc%5D='); + expect(utils.stringify({a: {b: {c: false}}})).to.equal( + 'a%5Bb%5D%5Bc%5D=false' + ); + }); + + it('stringifies an array value', function() { + expect(utils.stringify({a: ['b', 'c', 'd']})).to.equal( + 'a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d' + ); + }); + + it('should omit object key/value pair when value is empty array', function() { + expect(utils.stringify({a: [], b: 'zz'})).to.equal('b=zz'); + }); + + it('stringify an array with multiple items with a comma inside', function() { + expect(utils.stringify({a: ['b,c', 'd']})).to.equal( + 'a%5B0%5D=b%2Cc&a%5B1%5D=d' + ); + }); + it('stringifies nested array', function() { + expect(utils.stringify({a: {b: ['c', 'd']}})).to.equal( + 'a%5Bb%5D%5B0%5D=c&a%5Bb%5D%5B1%5D=d' + ); + }); + it('stringifies comma and empty array values', function() { + expect(utils.stringify({a: [',', '', 'c,d%']})).to.equal( + 'a%5B0%5D=%2C&a%5B1%5D=&a%5B2%5D=c%2Cd%25' + ); + }); + + it('stringifies comma and empty non-array values', function() { + expect(utils.stringify({a: ',', b: '', c: 'c,d%'})).to.equal( + 'a=%2C&b=&c=c%2Cd%25' + ); + }); + + it('stringifies an object inside an array', function() { + expect(utils.stringify({a: [{b: 'c'}]})).to.equal('a%5B0%5D%5Bb%5D=c'); + expect(utils.stringify({a: [{b: {c: [1]}}]})).to.equal( + 'a%5B0%5D%5Bb%5D%5Bc%5D%5B0%5D=1' + ); + }); + + it('stringifies an array with mixed objects and primitives', function() { + expect(utils.stringify({a: [{b: 1}, 2, 3]})).to.equal( + 'a%5B0%5D%5Bb%5D=1&a%5B1%5D=2&a%5B2%5D=3' + ); + }); + + it('stringifies an empty value', function() { + expect(utils.stringify({a: ''})).to.equal('a='); + expect(utils.stringify({a: null})).to.equal('a='); + expect(utils.stringify({a: '', b: ''})).to.equal('a=&b='); + expect(utils.stringify({a: null, b: ''})).to.equal('a=&b='); + expect(utils.stringify({a: {b: ''}})).to.equal('a%5Bb%5D='); + expect(utils.stringify({a: {b: null}})).to.equal('a%5Bb%5D='); + }); + + it('stringifies a null object', function() { + const obj = Object.create(null); + obj.a = 'b'; + expect(utils.stringify(obj)).to.equal('a=b'); + }); + + it('returns an empty string for invalid input', function() { + expect(utils.stringify(undefined)).to.equal(''); + expect(utils.stringify(false)).to.equal(''); + expect(utils.stringify(null)).to.equal(''); + expect(utils.stringify('')).to.equal(''); + }); + + it('stringifies an object with a null object as a child', function() { + const obj = {a: Object.create(null)}; + obj.a.b = 'c'; + expect(utils.stringify(obj)).to.equal('a%5Bb%5D=c'); + }); + + it('drops keys with a value of undefined', function() { + expect(utils.stringify({a: undefined})).to.equal(''); + expect(utils.stringify({a: 'b', c: undefined})).to.equal('a=b'); + expect(utils.stringify({a: {b: undefined, c: null}})).to.equal( + 'a%5Bc%5D=' + ); + expect(utils.stringify({a: {b: undefined, c: ''}})).to.equal('a%5Bc%5D='); + }); + + it('url encodes values', function() { + expect(utils.stringify({a: 'b c'})).to.equal('a=b%20c'); + expect(utils.stringify({a: 'b+c'})).to.equal('a=b%2Bc'); + expect(utils.stringify({a: 'b&c'})).to.equal('a=b%26c'); + expect(utils.stringify({a: 'b=c'})).to.equal('a=b%3Dc'); + expect(utils.stringify({a: 'b%c'})).to.equal('a=b%25c'); + }); + + it('stringifies a date', function() { + const now = new Date(); + expect(utils.stringify({a: now})).to.equal(`a=${now.toISOString()}`); + }); + + it('stringifies the weird object from qs', function() { + expect( + utils.stringify({'my weird field': '~q1!2"\'w$5&7/z8)?'}) + ).to.equal('my%20weird%20field=~q1%212%22%27w%245%267%2Fz8%29%3F'); + }); + + it('skips properties that are part of the object prototype', function() { + // eslint-disable-next-line no-extend-native + Object.prototype.crash = 'test'; + + expect(utils.stringify({a: 'b'})).to.equal('a=b'); + expect(utils.stringify({a: {b: 'c'}})).to.equal('a%5Bb%5D=c'); + + delete Object.prototype.crash; // Clean up + }); + + it('stringifies boolean values', function() { + expect(utils.stringify({a: true})).to.equal('a=true'); + expect(utils.stringify({a: {b: true}})).to.equal('a%5Bb%5D=true'); + expect(utils.stringify({b: false})).to.equal('b=false'); + expect(utils.stringify({b: {c: false}})).to.equal('b%5Bc%5D=false'); + }); + }); }); function handleWarnings(doWithShimmedConsoleWarn, onWarn): void {