diff --git a/frontend/lib/utils/buildQueryString.test.ts b/frontend/lib/utils/buildQueryString.test.ts new file mode 100644 index 0000000..9069c59 --- /dev/null +++ b/frontend/lib/utils/buildQueryString.test.ts @@ -0,0 +1,32 @@ +import { buildQueryString } from "./buildQueryString"; + +describe("buildQueryString", () => { + it("returns query string for flat object", () => { + expect(buildQueryString({ page: 2, limit: 10 })) + .toBe("?page=2&limit=10"); + }); + + it("returns empty string for empty object", () => { + expect(buildQueryString({})).toBe(""); + }); + + it("handles nested objects", () => { + expect(buildQueryString({ filter: { status: "active", premium: true } })) + .toBe("?filter%5Bstatus%5D=active&filter%5Bpremium%5D=true"); + }); + + it("handles arrays", () => { + expect(buildQueryString({ tags: ["vue", "react"] })) + .toBe("?tags%5B%5D=vue&tags%5B%5D=react"); + }); + + it("ignores null and undefined values", () => { + expect(buildQueryString({ q: "search", skip: null, empty: undefined })) + .toBe("?q=search"); + }); + + it("encodes special characters safely", () => { + expect(buildQueryString({ q: "hello world & test" })) + .toBe("?q=hello%20world%20%26%20test"); + }); +}); diff --git a/frontend/lib/utils/buildQueryString.ts b/frontend/lib/utils/buildQueryString.ts new file mode 100644 index 0000000..0030e6d --- /dev/null +++ b/frontend/lib/utils/buildQueryString.ts @@ -0,0 +1,74 @@ +/** + * Represents a valid query parameter value. + * + * - Can be a primitive (string, number, boolean) + * - Can be null/undefined (ignored in output) + * - Can be an array of query values + * - Can be an object whose values are query values + */ +export type QueryValue = + | string + | number + | boolean + | null + | undefined + | QueryValue[] + | { [key: string]: QueryValue }; + +/** + * Builds a query string from a parameter object. + * + * Features: + * - Supports nested objects (`filter[status]=active`) + * - Supports arrays (`tags[]=vue&tags[]=react`) + * - Ignores null and undefined values + * - URL-encodes all keys and values + * + * @example + * buildQueryString({ page: 2, limit: 10 }) + * // => "?page=2&limit=10" + * + * @example + * buildQueryString({ filter: { status: "active", premium: true } }) + * // => "?filter[status]=active&filter[premium]=true" + * + * @example + * buildQueryString({ tags: ["vue", "react"] }) + * // => "?tags[]=vue&tags[]=react" + * + * @param params - An object containing query parameters + * @returns A query string starting with `?`, or an empty string if no params + */ +export function buildQueryString(params: Record): string { + if (!params || Object.keys(params).length === 0) return ""; + + /** + * Recursively encodes a key-value pair into query string fragments. + * + * @param key - The current query key (e.g. "filter" or "tags[]") + * @param value - The value associated with the key + * @returns An array of encoded key=value strings + */ + const encode = (key: string, value: QueryValue): string[] => { + if (value === null || value === undefined) return []; + + if (Array.isArray(value)) { + return value.flatMap((item) => encode(`${key}[]`, item)); + } + + if (typeof value === "object") { + return Object.entries(value).flatMap(([subKey, subValue]) => + encode(`${key}[${subKey}]`, subValue) + ); + } + + // value is string | number | boolean + return [`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`]; + }; + + const query = Object.entries(params) + .flatMap(([key, value]) => encode(key, value)) + .join("&"); + + return query ? `?${query}` : ""; +}