Skip to content

Commit b893c44

Browse files
authored
Do not set content-type on body-less requests (#1826)
* Do not set content-type on body-less requests * Add docs * Remove commented code * Implement CR suggestions * Add change-set * Fix change-set * Post-rebase fix
1 parent f7bb00f commit b893c44

File tree

4 files changed

+167
-21
lines changed

4 files changed

+167
-21
lines changed

Diff for: .changeset/chilled-forks-rush.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": minor
3+
---
4+
5+
Do not set content-type on body-less requests

Diff for: docs/openapi-fetch/api.md

+11
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@ const { data, error } = await client.PUT("/submit", {
181181
});
182182
```
183183

184+
::: tip
185+
186+
For convenience, `openapi-fetch` sets `Content-Type` to `application/json` automatically
187+
for any request that provides value for the `body` parameter. When the `bodySerializer` returns an instance of `FormData`,
188+
`Content-Type` is omitted, allowing the browser to set it automatically with the correct message part boundary.
189+
190+
You can also set `Content-Type` manually through `headers` object either in the fetch options,
191+
or when instantiating the client.
192+
193+
:::
194+
184195
## Path serialization
185196

186197
openapi-fetch supports path serialization as [outlined in the 3.1 spec](https://swagger.io/docs/specification/serialization/#path). This happens automatically, based on the specific format in your OpenAPI schema:

Diff for: 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;

Diff for: packages/openapi-fetch/test/index.test.ts

+136-8
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import createClient, {
44
type BodySerializer,
55
type FetchOptions,
66
type MethodResponse,
7+
type HeadersOptions,
78
type Middleware,
89
type MiddlewareCallbackParams,
910
type QuerySerializerOptions,
1011
type Client,
1112
type PathBasedClient,
1213
createPathBasedClient,
1314
} from "../src/index.js";
14-
import { server, baseUrl, useMockRequestHandler, toAbsoluteURL } from "./fixtures/mock-server.js";
15+
import { baseUrl, server, toAbsoluteURL, useMockRequestHandler } from "./fixtures/mock-server.js";
1516
import type { paths } from "./fixtures/api.js";
1617

1718
beforeAll(() => {
@@ -819,12 +820,7 @@ describe("client", () => {
819820
await client.GET("/self");
820821

821822
// 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-
);
823+
expect(getRequest().headers).toEqual(new Headers(headers));
828824
});
829825

830826
it("can be overridden", async () => {
@@ -850,7 +846,6 @@ describe("client", () => {
850846
expect(getRequest().headers).toEqual(
851847
new Headers({
852848
"Cache-Control": "no-cache",
853-
"Content-Type": "application/json",
854849
}),
855850
);
856851
});
@@ -894,6 +889,139 @@ describe("client", () => {
894889
});
895890
});
896891

892+
describe("content-type", () => {
893+
const BODY_ACCEPTING_METHODS = [["PUT"], ["POST"], ["DELETE"], ["OPTIONS"], ["PATCH"]] as const;
894+
const ALL_METHODS = [...BODY_ACCEPTING_METHODS, ["GET"], ["HEAD"]] as const;
895+
896+
const fireRequestAndGetContentType = async (options: {
897+
defaultHeaders?: HeadersOptions;
898+
method: (typeof ALL_METHODS)[number][number];
899+
fetchOptions: FetchOptions<any>;
900+
}) => {
901+
const client = createClient<any>({ baseUrl, headers: options.defaultHeaders });
902+
const { getRequest } = useMockRequestHandler({
903+
baseUrl,
904+
method: "all",
905+
path: "/blogposts-optional",
906+
status: 200,
907+
});
908+
await client[options.method]("/blogposts-optional", options.fetchOptions as any);
909+
910+
const request = getRequest();
911+
return request.headers.get("content-type");
912+
};
913+
914+
it.each(ALL_METHODS)("no content-type for body-less requests - %s", async (method) => {
915+
const contentType = await fireRequestAndGetContentType({
916+
method,
917+
fetchOptions: {},
918+
});
919+
920+
expect(contentType).toBe(null);
921+
});
922+
923+
it.each(ALL_METHODS)("no content-type for `undefined` body requests - %s", async (method) => {
924+
const contentType = await fireRequestAndGetContentType({
925+
method,
926+
fetchOptions: {
927+
body: undefined,
928+
},
929+
});
930+
931+
expect(contentType).toBe(null);
932+
});
933+
934+
const BODIES = [{ prop: "a" }, {}, "", "str", null, false, 0, 1, new Date("2024-08-07T09:52:00.836Z")] 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+
// and it defaults to `text/plain;charset=UTF-8`, however the actual default value
984+
// is irrelevant and might be flaky across different fetch implementations
985+
// for us, it's important that it's not `application/json`
986+
expect(contentType).not.toBe("application/json");
987+
},
988+
);
989+
990+
it.each(METHOD_BODY_COMBINATIONS)(
991+
"specified content-type for body-full requests - %s, %j",
992+
async (method, body) => {
993+
const contentType = await fireRequestAndGetContentType({
994+
method,
995+
fetchOptions: {
996+
body,
997+
headers: {
998+
"content-type": "application/my-json",
999+
},
1000+
},
1001+
});
1002+
1003+
expect(contentType).toBe("application/my-json");
1004+
},
1005+
);
1006+
1007+
it.each(METHOD_BODY_COMBINATIONS)(
1008+
"specified content-type for body-full requests, even when default is suppressed - %s, %j",
1009+
async (method, body) => {
1010+
const contentType = await fireRequestAndGetContentType({
1011+
method,
1012+
fetchOptions: {
1013+
body,
1014+
headers: {
1015+
"content-type": "application/my-json",
1016+
},
1017+
},
1018+
});
1019+
1020+
expect(contentType).toBe("application/my-json");
1021+
},
1022+
);
1023+
});
1024+
8971025
describe("fetch", () => {
8981026
it("createClient", async () => {
8991027
function createCustomFetch(data: any) {

0 commit comments

Comments
 (0)