From 2b2468d83f11310b185c0c6b4228e13e33c672a3 Mon Sep 17 00:00:00 2001 From: Berrie Nachtweh Date: Fri, 16 Jan 2026 09:55:29 +0100 Subject: [PATCH 1/4] perf(query): remove need for external dependency --- docs/api.md | 14 ++-- package.json | 3 - pnpm-lock.yaml | 167 ---------------------------------------------- src/url.ts | 82 ++++++++++++++++++++--- tests/url.spec.ts | 103 ++++++++++++++++++++++++++++ types/index.ts | 23 +++++++ 6 files changed, 206 insertions(+), 186 deletions(-) diff --git a/docs/api.md b/docs/api.md index 13b2b20..2e3023b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,6 @@ > **new DynamicURL**(`url`): `DynamicURL` - #### Parameters ##### url @@ -23,7 +22,6 @@ > **resolve**(): `string` - Resolves and returns the final URL as a string. #### Returns @@ -40,12 +38,11 @@ url.setRouteParams({ citizen: "robespierre", hero: "ironman" }); console.log(url.resolve()); // "https://example.com/robespierre/ironman" ``` ---- +*** ### setQueryParams() -> **setQueryParams**(`query`): `DynamicURL` - +> **setQueryParams**(`query`, `options?`): `DynamicURL` Takes a query object and appends it to the URL as a query string. @@ -57,6 +54,10 @@ Takes a query object and appends it to the URL as a query string. An object representing the query parameters. +##### options? + +`IStringifyQueryOptions` + #### Returns `DynamicURL` @@ -69,13 +70,12 @@ url.setQueryParams({ citizen: "robespierre", hero: "ironman" }); console.log(url.resolve()); // "https://example.com?citizen=robespierre&hero=ironman" ``` ---- +*** ### setRouteParams() > **setRouteParams**(`replaceValue`): `DynamicURL` - Replaces route parameters in the URL with the provided values. #### Parameters diff --git a/package.json b/package.json index a669a5b..a821436 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,6 @@ "typescript-eslint": "^8.53.0", "vitest": "^4.0.17" }, - "dependencies": { - "qs": "^6.14.1" - }, "scripts": { "--- 🎣 Hooks ---": "", "prepare": "husky", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 218f39d..143519c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - qs: - specifier: ^6.14.1 - version: 6.14.1 devDependencies: '@commitlint/cli': specifier: ^20.3.1 @@ -717,14 +713,6 @@ packages: resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==} engines: {node: '>=6'} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -867,10 +855,6 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -885,21 +869,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -1044,21 +1016,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} @@ -1088,10 +1049,6 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1103,14 +1060,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} @@ -1339,10 +1288,6 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -1388,10 +1333,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1488,10 +1429,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} - engines: {node: '>=0.6'} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1551,22 +1488,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2469,16 +2390,6 @@ snapshots: cachedir@2.3.0: {} - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - callsites@3.1.0: {} chai@6.2.2: {} @@ -2628,12 +2539,6 @@ snapshots: dependencies: is-obj: 2.0.0 - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - emoji-regex@8.0.0: {} entities@4.5.0: {} @@ -2644,16 +2549,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -2841,28 +2738,8 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - git-raw-commits@4.0.0: dependencies: dargs: 8.1.0 @@ -2902,20 +2779,12 @@ snapshots: globals@14.0.0: {} - gopd@1.2.0: {} - graceful-fs@4.2.11: {} has-flag@3.0.0: {} has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - homedir-polyfill@1.0.3: dependencies: parse-passwd: 1.0.0 @@ -3118,8 +2987,6 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - math-intrinsics@1.1.0: {} - mdurl@2.0.0: {} meow@12.1.1: {} @@ -3153,8 +3020,6 @@ snapshots: natural-compare@1.4.0: {} - object-inspect@1.13.4: {} - obug@2.1.1: {} once@1.4.0: @@ -3245,10 +3110,6 @@ snapshots: punycode@2.3.1: {} - qs@6.14.1: - dependencies: - side-channel: 1.1.0 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3322,34 +3183,6 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} signal-exit@3.0.7: {} diff --git a/src/url.ts b/src/url.ts index 833b564..425c7e9 100644 --- a/src/url.ts +++ b/src/url.ts @@ -1,8 +1,69 @@ -// Vendor -import qs from "qs"; - // Types -import type { TParamsObject } from "../types"; +import type { TParamsObject, IStringifyQueryOptions } from "../types"; + +// Defaults +const MAX_DEPTH_DEFAULT = 5; +const SEPARATOR_DEFAULT = "&"; +const PREFIX_DEFAULT = ""; +const SKIPP_NULLS_DEFAULT = true; + +function stringify( + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + obj: Record, + options?: IStringifyQueryOptions +): string { + const { + maxDepth = MAX_DEPTH_DEFAULT, + prefix = PREFIX_DEFAULT, + separator = SEPARATOR_DEFAULT, + skipNulls = SKIPP_NULLS_DEFAULT, + } = options || {}; + + const stringifyRecursive = ( + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + obj: Record, + prefix: string, + depth = 0 + ): string => { + const result = Object.entries(obj).reduce((queryString, [key, value]) => { + // Handle skipNulls option + if (skipNulls && (value === null || value === undefined)) { + return queryString; + } + + const encodedKey = prefix + ? encodeURIComponent(`${prefix}[${key}]`) + : encodeURIComponent(key); + + // Check if value is an object and we haven't exceeded max depth + if (typeof value === "object" && value !== null && depth < maxDepth) { + const nestedQuery = stringifyRecursive(value, encodedKey, depth + 1); + + // Only append if nestedQuery has content + if (!nestedQuery) { + return queryString; + } + + return queryString + ? `${queryString}${separator}${nestedQuery}` + : nestedQuery; + } else { + const encodedValue = + typeof value === "object" && value !== null + ? "" + : encodeURIComponent(value); + const keyValueString = `${encodedKey}=${encodedValue}`; + return queryString + ? `${queryString}${separator}${keyValueString}` + : keyValueString; + } + }, ""); + + return result; + }; + + return stringifyRecursive(obj, prefix); +} export class DynamicURL { private url: string; @@ -15,6 +76,12 @@ export class DynamicURL { * Takes a query object and appends it to the URL as a query string. * * @param query - An object representing the query parameters. + * @param options - Options for stringifying the query parameters. + * @param options.prefix - Prefix for the query parameters (default: ""). + * @param options.maxDepth - Maximum depth for nested objects (default: 5). + * @param options.separator - Separator for the query parameters (default: "&"). + * @param options.skipNulls - Whether to skip null or undefined values (default: true). + * @see {@link IStringifyQueryOptions} * @returns {DynamicURL} * * @example @@ -25,11 +92,8 @@ export class DynamicURL { * ``` */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - setQueryParams(query: Record) { - const queryString = qs.stringify(query, { - skipNulls: true, - encode: true, - }); + setQueryParams(query: Record, options?: IStringifyQueryOptions) { + const queryString = stringify(query, options); if (queryString) { this.url = `${this.url}?${queryString}`; diff --git a/tests/url.spec.ts b/tests/url.spec.ts index 6c0d54e..7561530 100644 --- a/tests/url.spec.ts +++ b/tests/url.spec.ts @@ -92,4 +92,107 @@ describe("DynamicURL", () => { expect(url.resolve()).toBe("https://example.com/robespierre/{hero}"); }); + + test("should respect maxDepth option and stop at specified depth", () => { + const url = new DynamicURL("https://example.com"); + const deeplyNested = { + level1: { + level2: { + level3: { + level4: { + level5: { + level6: "too deep", + }, + }, + }, + }, + }, + }; + url.setQueryParams(deeplyNested, { maxDepth: 3 }); + + // Should only go 3 levels deep, stopping before level4 + const result = url.resolve(); + expect(result).toContain("level1"); + expect(result).toContain("level2"); + expect(result).toContain("level3"); + expect(result).toContain("level4"); + expect(result).not.toContain("level5"); + expect(result).not.toContain("level6"); + }); + + test("should handle skipNulls: false option", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams( + { citizen: "robespierre", hero: null }, + { skipNulls: false } + ); + + expect(url.resolve()).toBe( + "https://example.com?citizen=robespierre&hero=null" + ); + }); + + test("should use custom separator option", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams( + { citizen: "robespierre", hero: "ironman" }, + { separator: ";" } + ); + + expect(url.resolve()).toBe( + "https://example.com?citizen=robespierre;hero=ironman" + ); + }); + + test("should handle nested objects that result in empty strings when maxDepth is exceeded", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams( + { + shallow: "value", + deep: { a: { b: { c: { d: { e: "too deep" } } } } }, + }, + { maxDepth: 2 } + ); + + const result = url.resolve(); + expect(result).toContain("shallow=value"); + }); + + test("should skip null values in nested objects when skipNulls is true", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams({ + citizen: "robespierre", + marvel: { hero: "ironman", villain: null, sidekick: undefined }, + }); + + const result = url.resolve(); + expect(result).toContain("citizen=robespierre"); + expect(result).toContain("marvel%5Bhero%5D=ironman"); + expect(result).not.toContain("villain"); + expect(result).not.toContain("sidekick"); + }); + + test("should handle nested object as the first query param", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams({ + marvel: { hero: "ironman", villain: "thanos" }, + }); + + const result = url.resolve(); + expect(result).toBe( + "https://example.com?marvel%5Bhero%5D=ironman&marvel%5Bvillain%5D=thanos" + ); + }); + + test("should skip nested objects with only null values", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams({ + citizen: "robespierre", + emptyNested: { villain: null, sidekick: undefined }, + }); + + const result = url.resolve(); + expect(result).toBe("https://example.com?citizen=robespierre"); + expect(result).not.toContain("emptyNested"); + }); }); diff --git a/types/index.ts b/types/index.ts index e24b8dc..657cb29 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1 +1,24 @@ export type TParamsObject = Record; + +export interface IStringifyQueryOptions { + /** + * Prefix for the query parameters. + * @default "" + */ + prefix?: string; + /** + * Separator for the query parameters. + * @default "&" + */ + separator?: string; + /** + * Whether to skip null or undefined values. + * @default true + */ + skipNulls?: boolean; + /** + * Maximum depth for nested objects. + * @default 5 + */ + maxDepth?: number; +} From 2a2a24eebe022d1f7098fab0e6e1ce3dbf78d997 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:18:30 +0100 Subject: [PATCH 2/4] fix(query): prevent double encoding --- src/url.ts | 10 ++++++---- tests/url.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/url.ts b/src/url.ts index 425c7e9..4738ac9 100644 --- a/src/url.ts +++ b/src/url.ts @@ -31,13 +31,13 @@ function stringify( return queryString; } - const encodedKey = prefix - ? encodeURIComponent(`${prefix}[${key}]`) - : encodeURIComponent(key); + // Build the unencoded key path first + const keyPath = prefix ? `${prefix}[${key}]` : key; // Check if value is an object and we haven't exceeded max depth if (typeof value === "object" && value !== null && depth < maxDepth) { - const nestedQuery = stringifyRecursive(value, encodedKey, depth + 1); + // Pass unencoded keyPath as prefix to prevent double encoding in recursive calls + const nestedQuery = stringifyRecursive(value, keyPath, depth + 1); // Only append if nestedQuery has content if (!nestedQuery) { @@ -48,6 +48,8 @@ function stringify( ? `${queryString}${separator}${nestedQuery}` : nestedQuery; } else { + // Encode only once at the leaf level + const encodedKey = encodeURIComponent(keyPath); const encodedValue = typeof value === "object" && value !== null ? "" diff --git a/tests/url.spec.ts b/tests/url.spec.ts index 7561530..1040188 100644 --- a/tests/url.spec.ts +++ b/tests/url.spec.ts @@ -195,4 +195,27 @@ describe("DynamicURL", () => { expect(result).toBe("https://example.com?citizen=robespierre"); expect(result).not.toContain("emptyNested"); }); + + test("should not double-encode nested objects at 3+ levels deep", () => { + const url = new DynamicURL("https://example.com"); + url.setQueryParams({ + level1: { + level2: { + level3: "value", + }, + }, + }); + + const result = url.resolve(); + // Should have single encoding: level1[level2][level3] + expect(result).toBe( + "https://example.com?level1%5Blevel2%5D%5Blevel3%5D=value" + ); + + // Verify decoded key is correct + const queryString = result.split("?")[1]; + const params = new URLSearchParams(queryString); + const keys = Array.from(params.keys()); + expect(keys[0]).toBe("level1[level2][level3]"); + }); }); From dba2e50d09d34a226172e2d5c26ed7344fee3fe1 Mon Sep 17 00:00:00 2001 From: Berrie Nachtweh Date: Fri, 16 Jan 2026 10:21:30 +0100 Subject: [PATCH 3/4] refactor(url): typo in skip nulls constant --- src/url.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/url.ts b/src/url.ts index 4738ac9..3e9c195 100644 --- a/src/url.ts +++ b/src/url.ts @@ -5,7 +5,7 @@ import type { TParamsObject, IStringifyQueryOptions } from "../types"; const MAX_DEPTH_DEFAULT = 5; const SEPARATOR_DEFAULT = "&"; const PREFIX_DEFAULT = ""; -const SKIPP_NULLS_DEFAULT = true; +const SKIP_NULLS_DEFAULT = true; function stringify( /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -16,7 +16,7 @@ function stringify( maxDepth = MAX_DEPTH_DEFAULT, prefix = PREFIX_DEFAULT, separator = SEPARATOR_DEFAULT, - skipNulls = SKIPP_NULLS_DEFAULT, + skipNulls = SKIP_NULLS_DEFAULT, } = options || {}; const stringifyRecursive = ( From 4d29c8c2a8587d1c2693b90c3e24c2c72c5ecfd7 Mon Sep 17 00:00:00 2001 From: Berrie Nachtweh Date: Fri, 16 Jan 2026 10:27:12 +0100 Subject: [PATCH 4/4] fix(url): start recursive stringify at level 1 --- src/url.ts | 2 +- tests/url.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/url.ts b/src/url.ts index 3e9c195..f6a62a0 100644 --- a/src/url.ts +++ b/src/url.ts @@ -23,7 +23,7 @@ function stringify( /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ obj: Record, prefix: string, - depth = 0 + depth = 1 ): string => { const result = Object.entries(obj).reduce((queryString, [key, value]) => { // Handle skipNulls option diff --git a/tests/url.spec.ts b/tests/url.spec.ts index 1040188..566d845 100644 --- a/tests/url.spec.ts +++ b/tests/url.spec.ts @@ -115,7 +115,7 @@ describe("DynamicURL", () => { expect(result).toContain("level1"); expect(result).toContain("level2"); expect(result).toContain("level3"); - expect(result).toContain("level4"); + expect(result).not.toContain("level4"); expect(result).not.toContain("level5"); expect(result).not.toContain("level6"); });