Skip to content

Commit 80e2b93

Browse files
committed
Do not set content-type on body-less requests
1 parent 6038f8f commit 80e2b93

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 !== undefined) {
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
@@ -4,14 +4,16 @@ import createClient, {
44
type BodySerializer,
55
type FetchOptions,
66
type MethodResponse,
7+
type FetchOptions,
8+
type HeadersOptions,
79
type Middleware,
810
type MiddlewareCallbackParams,
911
type QuerySerializerOptions,
1012
type Client,
1113
type PathBasedClient,
1214
createPathBasedClient,
1315
} from "../src/index.js";
14-
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js";
16+
import { baseUrl, server, toAbsoluteURL, useMockRequestHandler } from "./fixtures/mock-server.js";
1517
import type { paths } from "./fixtures/api.js";
1618

1719
beforeAll(() => {
@@ -819,12 +821,7 @@ describe("client", () => {
819821
await client.GET("/self");
820822

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

830827
it("can be overridden", async () => {
@@ -850,7 +847,6 @@ describe("client", () => {
850847
expect(getRequest().headers).toEqual(
851848
new Headers({
852849
"Cache-Control": "no-cache",
853-
"Content-Type": "application/json",
854850
}),
855851
);
856852
});
@@ -894,6 +890,139 @@ describe("client", () => {
894890
});
895891
});
896892

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

0 commit comments

Comments
 (0)