Skip to content

Commit fa32db3

Browse files
committed
Do not set content-type on body-less requests
1 parent 3694a3e commit fa32db3

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 */
@@ -43,7 +39,6 @@ export default function createClient(clientOptions) {
4339
if (baseUrl.endsWith("/")) {
4440
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
4541
}
46-
baseHeaders = mergeHeaders(DEFAULT_HEADERS, baseHeaders);
4742
const middlewares = [];
4843

4944
/**
@@ -59,6 +54,7 @@ export default function createClient(clientOptions) {
5954
parseAs = "json",
6055
querySerializer: requestQuerySerializer,
6156
bodySerializer = globalBodySerializer ?? defaultBodySerializer,
57+
body,
6258
...init
6359
} = fetchOptions || {};
6460

@@ -76,19 +72,25 @@ export default function createClient(clientOptions) {
7672
});
7773
}
7874

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

9395
let id;
9496
let options;

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

+137-8
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { HttpResponse, type StrictResponse } from "msw";
22
import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest";
33
import createClient, {
4+
type FetchOptions,
5+
type HeadersOptions,
46
type Middleware,
57
type MiddlewareCallbackParams,
68
type QuerySerializerOptions,
79
} from "../src/index.js";
8-
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js";
10+
import { baseUrl, server, toAbsoluteURL, useMockRequestHandler } from "./fixtures/mock-server.js";
911
import type { paths } from "./fixtures/api.js";
1012

1113
beforeAll(() => {
@@ -784,12 +786,7 @@ describe("client", () => {
784786
await client.GET("/self");
785787

786788
// assert default headers were passed
787-
expect(getRequest().headers).toEqual(
788-
new Headers({
789-
...headers, // assert new header got passed
790-
"Content-Type": "application/json", // probably doesn’t need to get tested, but this was simpler than writing lots of code to ignore these
791-
}),
792-
);
789+
expect(getRequest().headers).toEqual(new Headers(headers));
793790
});
794791

795792
it("can be overridden", async () => {
@@ -815,7 +812,6 @@ describe("client", () => {
815812
expect(getRequest().headers).toEqual(
816813
new Headers({
817814
"Cache-Control": "no-cache",
818-
"Content-Type": "application/json",
819815
}),
820816
);
821817
});
@@ -859,6 +855,139 @@ describe("client", () => {
859855
});
860856
});
861857

858+
describe("content-type", () => {
859+
const BODY_ACCEPTING_METHODS = [["PUT"], ["POST"], ["DELETE"], ["OPTIONS"], ["PATCH"]] as const;
860+
const ALL_METHODS = [...BODY_ACCEPTING_METHODS, ["GET"], ["HEAD"]] as const;
861+
862+
const fireRequestAndGetContentType = async (options: {
863+
defaultHeaders?: HeadersOptions;
864+
method: (typeof ALL_METHODS)[number][number];
865+
fetchOptions: FetchOptions<any>;
866+
}) => {
867+
const client = createClient<any>({ baseUrl, headers: options.defaultHeaders });
868+
const { getRequest } = useMockRequestHandler({
869+
baseUrl,
870+
method: "all",
871+
path: "/blogposts-optional",
872+
status: 200,
873+
});
874+
await client[options.method]("/blogposts-optional", options.fetchOptions as any);
875+
876+
const request = getRequest();
877+
return request.headers.get("content-type");
878+
};
879+
880+
it.each(ALL_METHODS)("no content-type for body-less requests - %s", async (method) => {
881+
const contentType = await fireRequestAndGetContentType({
882+
method,
883+
fetchOptions: {},
884+
});
885+
886+
expect(contentType).toBe(null);
887+
});
888+
889+
it.each(ALL_METHODS)("no content-type for `undefined` body requests - %s", async (method) => {
890+
const contentType = await fireRequestAndGetContentType({
891+
method,
892+
fetchOptions: {
893+
body: undefined,
894+
},
895+
});
896+
897+
expect(contentType).toBe(null);
898+
});
899+
900+
const BODIES = [{ prop: "a" }, {}, "", "str", null, false, 0, 1, new Date("2024-08-07T09:52:00.836Z")] as const;
901+
// const BODIES = ["str"] as const;
902+
const METHOD_BODY_COMBINATIONS = BODY_ACCEPTING_METHODS.flatMap(([method]) =>
903+
BODIES.map((body) => [method, body] as const),
904+
);
905+
906+
it.each(METHOD_BODY_COMBINATIONS)(
907+
"implicit default content-type for body-full requests - %s, %j",
908+
async (method, body) => {
909+
const contentType = await fireRequestAndGetContentType({
910+
method,
911+
fetchOptions: {
912+
body,
913+
},
914+
});
915+
916+
expect(contentType).toBe("application/json");
917+
},
918+
);
919+
920+
it.each(METHOD_BODY_COMBINATIONS)(
921+
"provided default content-type for body-full requests - %s, %j",
922+
async (method, body) => {
923+
const contentType = await fireRequestAndGetContentType({
924+
defaultHeaders: {
925+
"content-type": "application/my-json",
926+
},
927+
method,
928+
fetchOptions: {
929+
body,
930+
},
931+
});
932+
933+
expect(contentType).toBe("application/my-json");
934+
},
935+
);
936+
937+
it.each(METHOD_BODY_COMBINATIONS)(
938+
"native-fetch default content-type for body-full requests, when default is suppressed - %s, %j",
939+
async (method, body) => {
940+
const contentType = await fireRequestAndGetContentType({
941+
defaultHeaders: {
942+
"content-type": null,
943+
},
944+
method,
945+
fetchOptions: {
946+
body,
947+
},
948+
});
949+
// the fetch implementation won't allow sending a body without content-type,
950+
// so it invents one up and sends it, hopefully this will be consistent across
951+
// local environments and won't make the tests flaky
952+
expect(contentType).toBe("text/plain;charset=UTF-8");
953+
},
954+
);
955+
956+
it.each(METHOD_BODY_COMBINATIONS)(
957+
"specified content-type for body-full requests - %s, %j",
958+
async (method, body) => {
959+
const contentType = await fireRequestAndGetContentType({
960+
method,
961+
fetchOptions: {
962+
body,
963+
headers: {
964+
"content-type": "application/my-json",
965+
},
966+
},
967+
});
968+
969+
expect(contentType).toBe("application/my-json");
970+
},
971+
);
972+
973+
it.each(METHOD_BODY_COMBINATIONS)(
974+
"specified content-type for body-full requests, even when default is suppressed - %s, %j",
975+
async (method, body) => {
976+
const contentType = await fireRequestAndGetContentType({
977+
method,
978+
fetchOptions: {
979+
body,
980+
headers: {
981+
"content-type": "application/my-json",
982+
},
983+
},
984+
});
985+
986+
expect(contentType).toBe("application/my-json");
987+
},
988+
);
989+
});
990+
862991
describe("fetch", () => {
863992
it("createClient", async () => {
864993
function createCustomFetch(data: any) {

0 commit comments

Comments
 (0)