diff --git a/.changeset/khaki-clowns-fly.md b/.changeset/khaki-clowns-fly.md new file mode 100644 index 000000000..c08209888 --- /dev/null +++ b/.changeset/khaki-clowns-fly.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Replace `instanceof Function` with `typeof === 'function'` to fix form-urlencoded body serialization in environments with separate JavaScript realms (e.g., Jest with experimental VM modules). Also fixes missing `headers` parameter in the type definition for `defaultBodySerializer`. diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 5bca81075..83b603188 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -322,7 +322,7 @@ export declare function createQuerySerializer( export declare function defaultPathSerializer(pathname: string, pathParams: Record): string; /** Serialize body object to string */ -export declare function defaultBodySerializer(body: T): string; +export declare function defaultBodySerializer(body: T, headers?: HeadersOptions): T | string; /** Construct URL string from baseUrl and handle path and query params */ export declare function createFinalURL( diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 4c7250fb0..9cb013480 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -627,7 +627,7 @@ export function defaultBodySerializer(body, headers) { } if (headers) { const contentType = - headers.get instanceof Function + typeof headers.get === "function" ? (headers.get("Content-Type") ?? headers.get("content-type")) : (headers["Content-Type"] ?? headers["content-type"]); if (contentType === "application/x-www-form-urlencoded") { diff --git a/packages/openapi-fetch/test/common/request.test.ts b/packages/openapi-fetch/test/common/request.test.ts index 6a836520e..e3a5a638b 100644 --- a/packages/openapi-fetch/test/common/request.test.ts +++ b/packages/openapi-fetch/test/common/request.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import createClient, { type BodySerializer, type FetchOptions } from "../../src/index.js"; +import createClient, { type BodySerializer, defaultBodySerializer, type FetchOptions } from "../../src/index.js"; import { createObservedClient, headersToObj } from "../helpers.js"; import type { components, paths } from "./schemas/common.js"; @@ -278,6 +278,59 @@ describe("request", () => { expect(bodyUsed).toBe(true); expect(bodyText).toBe("key1=value1&key2=value2"); }); + + test("`application/x-www-form-urlencoded` body with Headers instance", async () => { + const { bodyUsed, bodyText } = await fireRequestAndGetBodyInformation({ + method: "POST", + fetchOptions: { + body: { key1: "value1", key2: "value2" }, + headers: new Headers({ "Content-Type": "application/x-www-form-urlencoded" }), + }, + }); + + expect(bodyUsed).toBe(true); + expect(bodyText).toBe("key1=value1&key2=value2"); + }); + }); + + describe("defaultBodySerializer", () => { + test("serializes to JSON by default", () => { + const body = { key1: "value1", key2: "value2" }; + expect(defaultBodySerializer(body, undefined)).toBe(JSON.stringify(body)); + }); + + test("serializes to URLSearchParams with plain object headers", () => { + const body = { key1: "value1", key2: "value2" }; + const headers = { "Content-Type": "application/x-www-form-urlencoded" }; + expect(defaultBodySerializer(body, headers)).toBe("key1=value1&key2=value2"); + }); + + test("serializes to URLSearchParams with Headers instance", () => { + const body = { key1: "value1", key2: "value2" }; + const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded" }); + expect(defaultBodySerializer(body, headers)).toBe("key1=value1&key2=value2"); + }); + + test("serializes to URLSearchParams with lowercase content-type header", () => { + const body = { key1: "value1", key2: "value2" }; + const headers = new Headers({ "content-type": "application/x-www-form-urlencoded" }); + expect(defaultBodySerializer(body, headers)).toBe("key1=value1&key2=value2"); + }); + + test("works with Headers-like object that has get method", () => { + // Simulates cross-realm scenario where instanceof Function fails but typeof works + const body = { key1: "value1", key2: "value2" }; + const headersLike = { + get: (name: string) => (name.toLowerCase() === "content-type" ? "application/x-www-form-urlencoded" : null), + }; + expect(defaultBodySerializer(body, headersLike as any)).toBe("key1=value1&key2=value2"); + }); + + test("returns FormData as-is", () => { + const formData = new FormData(); + formData.append("key1", "value1"); + expect(defaultBodySerializer(formData, undefined)).toBe(formData); + }); }); test("cookie header is preserved", async () => {