diff --git a/.changeset/fix-empty-error-body-truthy.md b/.changeset/fix-empty-error-body-truthy.md new file mode 100644 index 000000000..f31b797c1 --- /dev/null +++ b/.changeset/fix-empty-error-body-truthy.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Fix `error` being `undefined` when a server returns a 4xx/5xx status with no response body. Previously `if (error)` checks would silently pass even on error responses. The `error` field now contains the HTTP status text (e.g. `"Not Found"`) or status code string (e.g. `"500"`) when the body is empty, making `if (error)` reliable for all error responses. diff --git a/packages/openapi-fetch/biome.json b/packages/openapi-fetch/biome.json index 99724ae16..f32a6de1e 100644 --- a/packages/openapi-fetch/biome.json +++ b/packages/openapi-fetch/biome.json @@ -3,7 +3,7 @@ "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "extends": "//", "files": { - "includes": ["src/**", "test/**", "!**/examples/**", "!test/**/schemas/**", "!test/bench/**/*.min.js"] + "includes": ["src/**", "test/**", "!**/examples", "!test/**/schemas", "!test/bench/**/*.min.js"] }, "linter": { "rules": { diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 4c7250fb0..5df9636cf 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -4,7 +4,7 @@ const PATH_PARAM_RE = /\{[^{}]+\}/g; const supportsRequestInitExt = () => { return ( typeof process === "object" && - Number.parseInt(process?.versions?.node?.substring(0, 2)) >= 18 && + Number.parseInt(process?.versions?.node?.substring(0, 2), 10) >= 18 && process.versions.undici ); }; @@ -242,7 +242,7 @@ export default function createClient(clientOptions) { request.method === "HEAD" || (contentLength === "0" && !response.headers.get("Transfer-Encoding")?.includes("chunked")) ) { - return response.ok ? { data: undefined, response } : { error: undefined, response }; + return response.ok ? { data: undefined, response } : { error: response.statusText || String(response.status), response }; } // parse response (falling back to .text() when necessary) @@ -267,8 +267,7 @@ export default function createClient(clientOptions) { // handle errors (use text() when no content-length to safely handle empty bodies from proxies) const raw = await response.text(); if (!raw) { - // empty error body - return undefined to be consistent with status 204 handling - return { error: undefined, response }; + return { error: response.statusText || String(response.status), response }; } let error = raw; try { @@ -625,6 +624,9 @@ export function defaultBodySerializer(body, headers) { if (body instanceof FormData) { return body; } + if (typeof body === "string") { + return body; + } if (headers) { const contentType = headers.get instanceof Function diff --git a/packages/openapi-fetch/test/common/create-client-e2e.test.js b/packages/openapi-fetch/test/common/create-client-e2e.test.js index 88536e083..51fe78f6d 100644 --- a/packages/openapi-fetch/test/common/create-client-e2e.test.js +++ b/packages/openapi-fetch/test/common/create-client-e2e.test.js @@ -67,7 +67,7 @@ const caToBuffer = (ca) => { const API_PORT = process.env.API_PORT || 4578; const app = express(); -app.get("/v1/foo", (req, res) => { +app.get("/v1/foo", (_req, res) => { res.send("bar"); }); diff --git a/packages/openapi-fetch/test/common/request.test.ts b/packages/openapi-fetch/test/common/request.test.ts index 6a836520e..be926327a 100644 --- a/packages/openapi-fetch/test/common/request.test.ts +++ b/packages/openapi-fetch/test/common/request.test.ts @@ -236,7 +236,19 @@ describe("request", () => { }); expect(bodyUsed).toBe(true); - expect(bodyText).toBe('""'); + expect(bodyText).toBe(""); + }); + + test.each(BODY_ACCEPTING_METHODS)("string body is passed through without JSON serialization - %s", async (method) => { + const { bodyUsed, bodyText } = await fireRequestAndGetBodyInformation({ + method, + fetchOptions: { + body: "pre-serialized string", + }, + }); + + expect(bodyUsed).toBe(true); + expect(bodyText).toBe("pre-serialized string"); }); test.each(BODY_ACCEPTING_METHODS)("`0` body (with body serializer) - %s", async (method) => { diff --git a/packages/openapi-fetch/test/common/response.test.ts b/packages/openapi-fetch/test/common/response.test.ts index 72f4f1f6e..0144cda10 100644 --- a/packages/openapi-fetch/test/common/response.test.ts +++ b/packages/openapi-fetch/test/common/response.test.ts @@ -127,7 +127,7 @@ describe("response", () => { describe("response object", () => { test.each([200, 404, 500] as const)("%s", async (status) => { - const client = createObservedClient({}, async (req) => + const client = createObservedClient({}, async (_req) => Response.json({ status, message: "OK" }, { status }), ); const result = await client.GET(status === 200 ? "/resources" : `/error-${status}`); diff --git a/packages/openapi-fetch/test/e2e/index.e2e.ts b/packages/openapi-fetch/test/e2e/index.e2e.ts index ecd6cfaa5..38a371704 100644 --- a/packages/openapi-fetch/test/e2e/index.e2e.ts +++ b/packages/openapi-fetch/test/e2e/index.e2e.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { type Page, test } from "@playwright/test"; // note: these tests load Chrome, Firefox, and Safari in Playwright to test a browser-realistic runtime. // the frontend is prepared via Vite to create a production-accurate app (and throw add’l type errors) diff --git a/packages/openapi-fetch/test/http-methods/delete.test.ts b/packages/openapi-fetch/test/http-methods/delete.test.ts index 16979ca7a..24ed10bb1 100644 --- a/packages/openapi-fetch/test/http-methods/delete.test.ts +++ b/packages/openapi-fetch/test/http-methods/delete.test.ts @@ -62,14 +62,31 @@ describe("DELETE", () => { // assert data is undefined for error response expect(data).toBeUndefined(); - // assert error is undefined for empty body (consistent with 204 and Content-Length: 0 handling) - expect(error).toBeUndefined(); + // assert error is truthy for error response with empty body + expect(error).toBe("500"); // assert response status is preserved expect(response.status).toBe(500); expect(response.ok).toBe(false); }); + test("error response with Content-Length: 0 returns truthy error", async () => { + const client = createObservedClient( + {}, + async () => new Response(null, { status: 404, statusText: "Not Found", headers: { "Content-Length": "0" } }), + ); + const { data, error, response } = await client.DELETE("/tags/{name}", { + params: { + path: { name: "Tag" }, + }, + }); + + expect(data).toBeUndefined(); + expect(error).toBe("Not Found"); + expect(response.status).toBe(404); + expect(response.ok).toBe(false); + }); + test("handles success response with empty body when Content-Length header is stripped by proxy", async () => { // Simulate proxy stripping Content-Length header from an empty success response const client = createObservedClient( diff --git a/packages/openapi-fetch/test/http-methods/get.test.ts b/packages/openapi-fetch/test/http-methods/get.test.ts index 991f72453..36ad107a4 100644 --- a/packages/openapi-fetch/test/http-methods/get.test.ts +++ b/packages/openapi-fetch/test/http-methods/get.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { createObservedClient } from "../helpers.js"; -import type { components, paths } from "./schemas/get.js"; +import type { paths } from "./schemas/get.js"; describe("GET", () => { test("sends correct method", async () => { diff --git a/packages/openapi-fetch/test/middleware/middleware.test.ts b/packages/openapi-fetch/test/middleware/middleware.test.ts index 0a1fecdea..4ad25ad9c 100644 --- a/packages/openapi-fetch/test/middleware/middleware.test.ts +++ b/packages/openapi-fetch/test/middleware/middleware.test.ts @@ -98,7 +98,7 @@ test("can modify response", async () => { test("returns original errors if nothing is returned", async () => { const actualError = new Error(); - const client = createObservedClient({}, async (req) => { + const client = createObservedClient({}, async (_req) => { throw actualError; }); client.use({