Skip to content

Commit 933eba9

Browse files
committed
Do not set content-type on body-less requests
1 parent 246f3b7 commit 933eba9

File tree

2 files changed

+152
-21
lines changed

2 files changed

+152
-21
lines changed

packages/openapi-fetch/src/index.js

+15-13
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
// settings & const
2-
const DEFAULT_HEADERS = {
3-
"Content-Type": "application/json",
4-
};
5-
62
const PATH_PARAM_RE = /\{[^{}]+\}/g;
73

84
/** Add custom parameters to Request object */
@@ -41,7 +37,6 @@ export default function createClient(clientOptions) {
4137
...baseOptions
4238
} = { ...clientOptions };
4339
baseUrl = removeTrailingSlash(baseUrl);
44-
baseHeaders = mergeHeaders(DEFAULT_HEADERS, baseHeaders);
4540
const middlewares = [];
4641

4742
/**
@@ -58,6 +53,7 @@ export default function createClient(clientOptions) {
5853
parseAs = "json",
5954
querySerializer: requestQuerySerializer,
6055
bodySerializer = globalBodySerializer ?? defaultBodySerializer,
56+
body,
6157
...init
6258
} = fetchOptions || {};
6359
if (localBaseUrl) {
@@ -78,19 +74,25 @@ export default function createClient(clientOptions) {
7874
});
7975
}
8076

77+
const serializedBody = body === undefined ? undefined : bodySerializer(body);
78+
79+
const defaultHeaders =
80+
// with no body, we should not to set Content-Type
81+
serializedBody === undefined ||
82+
// if serialized body is FormData; browser will correctly set Content-Type & boundary expression
83+
serializedBody instanceof FormData
84+
? {}
85+
: {
86+
"Content-Type": "application/json",
87+
};
88+
8189
const requestInit = {
8290
redirect: "follow",
8391
...baseOptions,
8492
...init,
85-
headers: mergeHeaders(baseHeaders, headers, params.header),
93+
body: serializedBody,
94+
headers: mergeHeaders(defaultHeaders, baseHeaders, headers, params.header),
8695
};
87-
if (requestInit.body) {
88-
requestInit.body = bodySerializer(requestInit.body);
89-
// remove `Content-Type` if serialized body is FormData; browser will correctly set Content-Type & boundary expression
90-
if (requestInit.body instanceof FormData) {
91-
requestInit.headers.delete("Content-Type");
92-
}
93-
}
9496

9597
let id;
9698
let options;

packages/openapi-fetch/test/index.test.ts

+137-8
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import { HttpResponse, type StrictResponse } from "msw";
22
import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest";
33
import createClient, {
44
type MethodResponse,
5+
type FetchOptions,
6+
type HeadersOptions,
57
type Middleware,
68
type MiddlewareCallbackParams,
79
type QuerySerializerOptions,
810
type Client,
911
type PathBasedClient,
1012
createPathBasedClient,
1113
} from "../src/index.js";
12-
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js";
14+
import { baseUrl, server, toAbsoluteURL, useMockRequestHandler } from "./fixtures/mock-server.js";
1315
import type { paths } from "./fixtures/api.js";
1416

1517
beforeAll(() => {
@@ -817,12 +819,7 @@ describe("client", () => {
817819
await client.GET("/self");
818820

819821
// assert default headers were passed
820-
expect(getRequest().headers).toEqual(
821-
new Headers({
822-
...headers, // assert new header got passed
823-
"Content-Type": "application/json", // probably doesn’t need to get tested, but this was simpler than writing lots of code to ignore these
824-
}),
825-
);
822+
expect(getRequest().headers).toEqual(new Headers(headers));
826823
});
827824

828825
it("can be overridden", async () => {
@@ -848,7 +845,6 @@ describe("client", () => {
848845
expect(getRequest().headers).toEqual(
849846
new Headers({
850847
"Cache-Control": "no-cache",
851-
"Content-Type": "application/json",
852848
}),
853849
);
854850
});
@@ -892,6 +888,139 @@ describe("client", () => {
892888
});
893889
});
894890

891+
describe("content-type", () => {
892+
const BODY_ACCEPTING_METHODS = [["PUT"], ["POST"], ["DELETE"], ["OPTIONS"], ["PATCH"]] as const;
893+
const ALL_METHODS = [...BODY_ACCEPTING_METHODS, ["GET"], ["HEAD"]] as const;
894+
895+
const fireRequestAndGetContentType = async (options: {
896+
defaultHeaders?: HeadersOptions;
897+
method: (typeof ALL_METHODS)[number][number];
898+
fetchOptions: FetchOptions<any>;
899+
}) => {
900+
const client = createClient<any>({ baseUrl, headers: options.defaultHeaders });
901+
const { getRequest } = useMockRequestHandler({
902+
baseUrl,
903+
method: "all",
904+
path: "/blogposts-optional",
905+
status: 200,
906+
});
907+
await client[options.method]("/blogposts-optional", options.fetchOptions as any);
908+
909+
const request = getRequest();
910+
return request.headers.get("content-type");
911+
};
912+
913+
it.each(ALL_METHODS)("no content-type for body-less requests - %s", async (method) => {
914+
const contentType = await fireRequestAndGetContentType({
915+
method,
916+
fetchOptions: {},
917+
});
918+
919+
expect(contentType).toBe(null);
920+
});
921+
922+
it.each(ALL_METHODS)("no content-type for `undefined` body requests - %s", async (method) => {
923+
const contentType = await fireRequestAndGetContentType({
924+
method,
925+
fetchOptions: {
926+
body: undefined,
927+
},
928+
});
929+
930+
expect(contentType).toBe(null);
931+
});
932+
933+
const BODIES = [{ prop: "a" }, {}, "", "str", null, false, 0, 1, new Date("2024-08-07T09:52:00.836Z")] as const;
934+
// const BODIES = ["str"] as const;
935+
const METHOD_BODY_COMBINATIONS = BODY_ACCEPTING_METHODS.flatMap(([method]) =>
936+
BODIES.map((body) => [method, body] as const),
937+
);
938+
939+
it.each(METHOD_BODY_COMBINATIONS)(
940+
"implicit default content-type for body-full requests - %s, %j",
941+
async (method, body) => {
942+
const contentType = await fireRequestAndGetContentType({
943+
method,
944+
fetchOptions: {
945+
body,
946+
},
947+
});
948+
949+
expect(contentType).toBe("application/json");
950+
},
951+
);
952+
953+
it.each(METHOD_BODY_COMBINATIONS)(
954+
"provided default content-type for body-full requests - %s, %j",
955+
async (method, body) => {
956+
const contentType = await fireRequestAndGetContentType({
957+
defaultHeaders: {
958+
"content-type": "application/my-json",
959+
},
960+
method,
961+
fetchOptions: {
962+
body,
963+
},
964+
});
965+
966+
expect(contentType).toBe("application/my-json");
967+
},
968+
);
969+
970+
it.each(METHOD_BODY_COMBINATIONS)(
971+
"native-fetch default content-type for body-full requests, when default is suppressed - %s, %j",
972+
async (method, body) => {
973+
const contentType = await fireRequestAndGetContentType({
974+
defaultHeaders: {
975+
"content-type": null,
976+
},
977+
method,
978+
fetchOptions: {
979+
body,
980+
},
981+
});
982+
// the fetch implementation won't allow sending a body without content-type,
983+
// so it invents one up and sends it, hopefully this will be consistent across
984+
// local environments and won't make the tests flaky
985+
expect(contentType).toBe("text/plain;charset=UTF-8");
986+
},
987+
);
988+
989+
it.each(METHOD_BODY_COMBINATIONS)(
990+
"specified content-type for body-full requests - %s, %j",
991+
async (method, body) => {
992+
const contentType = await fireRequestAndGetContentType({
993+
method,
994+
fetchOptions: {
995+
body,
996+
headers: {
997+
"content-type": "application/my-json",
998+
},
999+
},
1000+
});
1001+
1002+
expect(contentType).toBe("application/my-json");
1003+
},
1004+
);
1005+
1006+
it.each(METHOD_BODY_COMBINATIONS)(
1007+
"specified content-type for body-full requests, even when default is suppressed - %s, %j",
1008+
async (method, body) => {
1009+
const contentType = await fireRequestAndGetContentType({
1010+
method,
1011+
fetchOptions: {
1012+
body,
1013+
headers: {
1014+
"content-type": "application/my-json",
1015+
},
1016+
},
1017+
});
1018+
1019+
expect(contentType).toBe("application/my-json");
1020+
},
1021+
);
1022+
});
1023+
8951024
describe("fetch", () => {
8961025
it("createClient", async () => {
8971026
function createCustomFetch(data: any) {

0 commit comments

Comments
 (0)