diff --git a/.changeset/great-zoos-live.md b/.changeset/great-zoos-live.md new file mode 100644 index 000000000..6d9286f71 --- /dev/null +++ b/.changeset/great-zoos-live.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +fixed `parseAs` behaviour being different for error responses diff --git a/.changeset/thirty-singers-join.md b/.changeset/thirty-singers-join.md new file mode 100644 index 000000000..2270bc54e --- /dev/null +++ b/.changeset/thirty-singers-join.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +fixed empty responses body causing error `Unexpected end of JSON input` diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index a7b26a1be..84dea3c6c 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -106,7 +106,7 @@ export type FetchResponse, Options, Media } | { data?: never; - error: ErrorResponse, Media>; + error: ParseAsResponse, Media>, Options>; response: Response; }; diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 1b8502c6f..04840f191 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -150,28 +150,40 @@ export default function createClient(clientOptions) { } } - // handle empty content - if (response.status === 204 || response.headers.get("Content-Length") === "0") { - return response.ok ? { data: undefined, response } : { error: undefined, response }; + const resultKey = response.ok ? "data" : "error"; + + /** + * handle empty content + * NOTE: Current browsers don't actually conform to the spec requirement to set the body property to null for responses with no body + * @see https://developer.mozilla.org/en-US/docs/Web/API/Response/body + */ + if (response.body === null || response.headers.get("Content-Length") === "0") { + return { [resultKey]: undefined, response }; } - // parse response (falling back to .text() when necessary) - if (response.ok) { - // if "stream", skip parsing entirely - if (parseAs === "stream") { - return { data: response.body, response }; - } - return { data: await response[parseAs](), response }; + if (parseAs === "stream") { + return { [resultKey]: response.body, response }; } - // handle errors - let error = await response.text(); + // parse response (falling back to .text() when necessary) try { - error = JSON.parse(error); // attempt to parse as JSON + const fallbackResponseClone = response.clone(); + + return { [resultKey]: await fallbackResponseClone[parseAs](), response }; } catch { - // noop + // handle errors + let data = await response.text(); + try { + data = JSON.parse(data); // attempt to parse as JSON + } catch { + // Handle empty content + if (data === "") { + data = undefined; + } + } + + return { [resultKey]: data, response }; } - return { error, response }; } return { diff --git a/packages/openapi-fetch/test/common/response.test.ts b/packages/openapi-fetch/test/common/response.test.ts index 7a3aacf39..cb674cc32 100644 --- a/packages/openapi-fetch/test/common/response.test.ts +++ b/packages/openapi-fetch/test/common/response.test.ts @@ -123,6 +123,22 @@ describe("response", () => { assertType(error); } }); + + test("fallback on null response", async () => { + const client = createObservedClient({}, async () => new Response(undefined, { status: 200 })); + + const { data, error } = await client.GET("/error-empty-response"); + expect(data).toBe(undefined); + expect(error).toBe(undefined); + }); + + test("fallback on empty body steam", async () => { + const client = createObservedClient({}, async () => new Response("", { status: 200 })); + + const { data, error } = await client.GET("/error-empty-response"); + expect(data).toBe(undefined); + expect(error).toBe(undefined); + }); }); describe("response object", () => { @@ -135,7 +151,7 @@ describe("response", () => { }); }); - describe("parseAs", () => { + describe("data parseAs", () => { const client = createObservedClient({}, async () => Response.json({})); test("text", async () => { @@ -192,4 +208,64 @@ describe("response", () => { } }); }); + + describe("error parseAs", () => { + const client = createObservedClient({}, async () => Response.json({}, { status: 500 })); + + test("text", async () => { + const { data, error } = (await client.GET("/resources", { + parseAs: "text", + })) satisfies { error?: string }; + if (data) { + throw new Error("parseAs text: error"); + } + expect(error).toBe("{}"); + }); + + test("arrayBuffer", async () => { + const { data, error } = (await client.GET("/resources", { + parseAs: "arrayBuffer", + })) satisfies { error?: ArrayBuffer }; + if (data) { + throw new Error("parseAs arrayBuffer: error"); + } + expect(error.byteLength).toBe("{}".length); + }); + + test("blob", async () => { + const { data, error } = (await client.GET("/resources", { + parseAs: "blob", + })) satisfies { error?: Blob }; + if (data) { + throw new Error("parseAs blob: error"); + } + expect(error.constructor.name).toBe("Blob"); + }); + + test("stream", async () => { + const { error } = (await client.GET("/resources", { + parseAs: "stream", + })) satisfies { error?: ReadableStream | null }; + if (!error) { + throw new Error("parseAs stream: error"); + } + + expect(error).toBeInstanceOf(ReadableStream); + const reader = error.getReader(); + const result = await reader.read(); + expect(result.value?.length).toBe(2); + }); + + test("use the selected content", async () => { + const client = createObservedClient({}, async () => + Response.json({ bar: "bar" }, { status: 500 }), + ); + const { error } = await client.GET("/media-multiple", { + headers: { Accept: "application/ld+json" }, + }); + if (error) { + assertType<{ bar: string }>(error); + } + }); + }); }); diff --git a/packages/openapi-fetch/test/common/schemas/common.d.ts b/packages/openapi-fetch/test/common/schemas/common.d.ts index b684c117e..13e7b96d9 100644 --- a/packages/openapi-fetch/test/common/schemas/common.d.ts +++ b/packages/openapi-fetch/test/common/schemas/common.d.ts @@ -190,6 +190,46 @@ export interface paths { patch?: never; trace?: never; }; + "/error-empty-response": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/error-default": { parameters: { query?: never; diff --git a/packages/openapi-fetch/test/common/schemas/common.yaml b/packages/openapi-fetch/test/common/schemas/common.yaml index 72e1ea6e2..7f3876d77 100644 --- a/packages/openapi-fetch/test/common/schemas/common.yaml +++ b/packages/openapi-fetch/test/common/schemas/common.yaml @@ -79,6 +79,13 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /error-empty-response: + get: + responses: + 200: + description: OK + 204: + description: No content /error-default: get: responses: diff --git a/packages/openapi-fetch/test/never-response/never-response.test.ts b/packages/openapi-fetch/test/never-response/never-response.test.ts index 43cdb9a4b..202ddb363 100644 --- a/packages/openapi-fetch/test/never-response/never-response.test.ts +++ b/packages/openapi-fetch/test/never-response/never-response.test.ts @@ -140,4 +140,32 @@ describe("GET", () => { expect(data).toBeUndefined(); expect(error).toBe("Unauthorized"); }); + + describe("handles error as", () => { + test("text", async () => { + const client = createObservedClient({}, async () => new Response("Unauthorized", { status: 401 })); + + const { data, error } = await client.GET("/posts", { parseAs: "text" }); + + expect(data).toBeUndefined(); + expect(error).toBe("Unauthorized"); + }); + + test("stream", async () => { + const client = createObservedClient({}, async () => new Response("Unauthorized", { status: 401 })); + + const { data, error } = (await client.GET("/posts", { parseAs: "stream" })) satisfies { + error?: ReadableStream | null; + }; + if (!error) { + throw new Error("parseAs stream: error"); + } + + expect(data).toBeUndefined(); + expect(error).toBeInstanceOf(ReadableStream); + const reader = error.getReader(); + const result = await reader.read(); + expect(result.value?.length).toBe(12); + }); + }); });