Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ tests/integration/backward-compatibility.integration.test.ts
src/index.ts
tests/unit/schemas/enum/enum.test.ts
tests/unit/schemas/union/union.test.ts
src/core/schemas/builders/enum/enum.ts
src/core/schemas/builders/enum/enum.ts
src/core/fetcher/createRequestUrl.ts
22 changes: 21 additions & 1 deletion src/core/fetcher/createRequestUrl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { toQueryString } from "../url/qs";

export function createRequestUrl(baseUrl: string, queryParameters?: Record<string, unknown>): string {
const queryString = toQueryString(queryParameters, { arrayFormat: "repeat" });
// Flatten any array values to comma-separated strings.
// Merge's REST API expects multi-value params (e.g. expand) as CSV,
// not as repeated query params.
const flattenedParams = queryParameters != null ? flattenArrayQueryParams(queryParameters) : undefined;
const queryString = toQueryString(flattenedParams, { arrayFormat: "repeat" });
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}

function flattenArrayQueryParams(params: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item) => typeof item !== "object" || item === null)
) {
result[key] = value.join(",");
} else {
result[key] = value;
}
}
return result;
}
6 changes: 3 additions & 3 deletions tests/unit/fetcher/createRequestUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ describe("Test createRequestUrl", () => {
expected: "https://api.example.com?key=value&another=param",
},
{
description: "should handle array query parameters",
description: "should handle array query parameters as comma-separated values",
baseUrl: BASE_URL,
queryParams: { items: ["a", "b", "c"] },
expected: "https://api.example.com?items=a&items=b&items=c",
expected: "https://api.example.com?items=a%2Cb%2Cc",
},
{
description: "should handle object query parameters",
Expand All @@ -42,7 +42,7 @@ describe("Test createRequestUrl", () => {
array: ["x", "y"],
object: { key: "value" },
},
expected: "https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value",
expected: "https://api.example.com?simple=value&array=x%2Cy&object%5Bkey%5D=value",
},
{
description: "should handle empty query parameters object",
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/multiple-expand-handling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createRequestUrl } from "../../src/core/fetcher/createRequestUrl";

describe("Multiple Expand Parameter Handling", () => {
describe("createRequestUrl flattens arrays to CSV", () => {
it("should join array values with commas instead of repeating params", () => {
const url = createRequestUrl("https://api.merge.dev/hris/v1/employees", {
expand: ["groups", "work_location"],
include_remote_data: true,
});
expect(url).toBe(
"https://api.merge.dev/hris/v1/employees?expand=groups%2Cwork_location&include_remote_data=true"
);
});

it("should handle single expand value (not array)", () => {
const url = createRequestUrl("https://api.merge.dev/hris/v1/employees", {
expand: "groups",
});
expect(url).toBe("https://api.merge.dev/hris/v1/employees?expand=groups");
});

it("should handle three or more expand values", () => {
const url = createRequestUrl("https://api.merge.dev/accounting/v1/contacts", {
expand: ["addresses", "phone_numbers", "company"],
});
expect(url).toBe(
"https://api.merge.dev/accounting/v1/contacts?expand=addresses%2Cphone_numbers%2Ccompany"
);
});

it("should handle undefined expand", () => {
const url = createRequestUrl("https://api.merge.dev/hris/v1/employees", {
expand: undefined,
cursor: "abc123",
});
expect(url).toBe("https://api.merge.dev/hris/v1/employees?cursor=abc123");
});
});

describe("SDK client serializes expand correctly", () => {
const mockOptions = {
apiKey: "test-api-key",
environment: "https://api.merge.dev",
};

it("should produce correct URL with array expand via fetcher", async () => {
const { Accounting } = require("../../src");

let capturedUrl: string | undefined;
const mockFetcher = jest.fn().mockImplementation((args: any) => {
// Reconstruct the URL as fetcherImpl would
capturedUrl = createRequestUrl(args.url, args.queryParameters);
return { ok: true, body: { results: [] } };
});

const accounting = new Accounting({
...mockOptions,
fetcher: mockFetcher,
});

await accounting.contacts.list({
expand: ["addresses", "phone_numbers", "company"],
});

expect(capturedUrl).toContain("expand=addresses%2Cphone_numbers%2Ccompany");
});

it("should produce correct URL with single expand via fetcher", async () => {
const { Accounting } = require("../../src");

let capturedUrl: string | undefined;
const mockFetcher = jest.fn().mockImplementation((args: any) => {
capturedUrl = createRequestUrl(args.url, args.queryParameters);
return { ok: true, body: { results: [] } };
});

const accounting = new Accounting({
...mockOptions,
fetcher: mockFetcher,
});

await accounting.contacts.list({
expand: "addresses",
});

expect(capturedUrl).toContain("expand=addresses");
// Should NOT contain a comma since it's a single value
expect(capturedUrl).not.toContain("%2C");
});
});
});
Loading