Skip to content

Commit 8ddd97b

Browse files
committed
feat(@merkur/plugin-http-client)!: normalize headers to Headers instance, add default Content-Type for body requests
- Add transformHeaders transformer that normalizes request.headers to a Headers instance (accepts plain objects and Headers instances) - Add transformHeaders as first entry in getDefaultTransformers pipeline - Set default Content-Type: application/json for body-bearing requests (non-GET/HEAD) when no Content-Type is already set in transformBody - Add changeset documenting all three breaking changes and migration steps - Add tests for Headers instance normalization, default Content-Type, and preservation of existing headers - Update website documentation
1 parent 17d0df9 commit 8ddd97b

6 files changed

Lines changed: 303 additions & 30 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@merkur/plugin-http-client": major
3+
---
4+
5+
**Breaking changes in `@merkur/plugin-http-client`**
6+
7+
### `request.headers` is now always a `Headers` instance
8+
9+
A new built-in `transformHeaders` transformer has been added as the first step in the default transformer pipeline. It normalizes `request.headers` to a [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) instance for every request.
10+
11+
**Migration:** Custom transformers that read or write `request.headers` using plain object access (e.g. `request.headers['Content-Type']` or `{ ...request.headers, ... }`) must be updated to use the `Headers` API:
12+
13+
```js
14+
// Before
15+
async transformRequest(widget, request, response) {
16+
return [{ ...request, headers: { ...request.headers, Authorization: `Bearer ${token}` } }, response];
17+
}
18+
19+
// After
20+
async transformRequest(widget, request, response) {
21+
const headers = new Headers(request.headers);
22+
headers.set('Authorization', `Bearer ${token}`);
23+
return [{ ...request, headers }, response];
24+
}
25+
```
26+
27+
### `getDefaultTransformers` now returns four transformers
28+
29+
`transformHeaders` is now the first entry in the array returned by `getDefaultTransformers()`. Code that relied on the exact length or index positions of the default transformer array must be updated.
30+
31+
If you supply a custom `transformers` array via `setDefaultConfig` **without** spreading `getDefaultTransformers()`, you must add `transformHeaders()` manually as the first transformer to ensure `request.headers` is always a `Headers` instance:
32+
33+
```js
34+
import { setDefaultConfig, transformHeaders, transformBody, transformQuery, transformTimeout } from '@merkur/plugin-http-client';
35+
36+
// Before — custom pipeline without getDefaultTransformers
37+
setDefaultConfig(widget, {
38+
transformers: [transformBody(), transformQuery(), transformTimeout(), myTransformer()],
39+
});
40+
41+
// After — add transformHeaders as the first entry
42+
setDefaultConfig(widget, {
43+
transformers: [transformHeaders(), transformBody(), transformQuery(), transformTimeout(), myTransformer()],
44+
});
45+
```
46+
47+
### Default `Content-Type: application/json` for body requests
48+
49+
`transformBody` now automatically sets `Content-Type: application/json` on requests that carry a `body` and use a method other than `GET` or `HEAD`, when no `Content-Type` header is already present. Previously, the header had to be set explicitly.
50+
51+
**Migration:** If you were sending a body with a non-JSON content type and relying on the absence of a default `Content-Type`, you must now explicitly set your desired `Content-Type` header:
52+
53+
```js
54+
// Explicitly set a non-JSON content type to override the default
55+
await widget.http.request({
56+
method: 'POST',
57+
path: '/upload',
58+
headers: { 'Content-Type': 'multipart/form-data' },
59+
body: formData,
60+
});
61+
```
62+
63+
### Polyfill required for build targets below ES2017 (ES8)
64+
65+
The `Headers` global (part of the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Headers)) is used at runtime by `transformHeaders`. Widgets built with a bundler target below ES2017 (e.g. `target: 'es5'` or `target: 'es6'` in webpack/Rollup) that run in environments without a native `Headers` implementation must add a polyfill such as [`whatwg-fetch`](https://github.com/github/fetch) or [`cross-fetch`](https://github.com/lquixada/cross-fetch).

packages/plugin-graphql-client/src/__tests__/indexSpec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ describe('createWidget method with graphql client plugin', () => {
103103
"query": {},
104104
"timeout": 15000,
105105
"transformers": [
106+
{
107+
"transformRequest": [Function],
108+
},
106109
{
107110
"transformRequest": [Function],
108111
"transformResponse": [Function],

packages/plugin-http-cache/src/__tests__/indexSpec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ describe('createWidget method with http client plugin', () => {
106106
"timeout": 15000,
107107
"transferServerCache": false,
108108
"transformers": [
109+
{
110+
"transformRequest": [Function],
111+
},
109112
{
110113
"transformRequest": [Function],
111114
"transformResponse": [Function],

packages/plugin-http-client/src/__tests__/indexSpec.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
httpClientPlugin,
44
setDefaultConfig,
55
getDefaultTransformers,
6+
transformHeaders,
67
} from '../index';
78

89
describe('createWidget method with http client plugin', () => {
@@ -96,6 +97,9 @@ describe('createWidget method with http client plugin', () => {
9697
{
9798
"transformResponse": [Function],
9899
},
100+
{
101+
"transformRequest": [Function],
102+
},
99103
{
100104
"transformRequest": [Function],
101105
"transformResponse": [Function],
@@ -234,6 +238,95 @@ describe('createWidget method with http client plugin', () => {
234238
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
235239
});
236240

241+
it('should set default Content-Type application/json for POST with body', async () => {
242+
const { request } = await widget.http.request({
243+
method: 'POST',
244+
path: '/path',
245+
body: { a: 'b' },
246+
headers: {},
247+
});
248+
249+
expect(request.headers.get('Content-Type')).toBe('application/json');
250+
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
251+
});
252+
253+
it('should not override explicit Content-Type when body is present', async () => {
254+
const { request } = await widget.http.request({
255+
method: 'POST',
256+
path: '/path',
257+
body: 'raw text',
258+
headers: {
259+
'Content-Type': 'text/plain',
260+
},
261+
});
262+
263+
expect(request.headers.get('Content-Type')).toBe('text/plain');
264+
expect(request.body).toBe('raw text');
265+
});
266+
267+
it('should not set default Content-Type for GET with body', async () => {
268+
const { request } = await widget.http.request({
269+
method: 'GET',
270+
path: '/path',
271+
body: { a: 'b' },
272+
headers: {},
273+
});
274+
275+
expect(request.headers.get('Content-Type')).toBeNull();
276+
});
277+
278+
it('should set default Content-Type when headers is a Headers instance', async () => {
279+
const { request } = await widget.http.request({
280+
method: 'POST',
281+
path: '/path',
282+
body: { a: 'b' },
283+
headers: new Headers(),
284+
});
285+
286+
expect(request.headers.get('Content-Type')).toBe('application/json');
287+
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
288+
});
289+
290+
it('should serialize body when headers is a Headers instance with Content-Type', async () => {
291+
const { request } = await widget.http.request({
292+
method: 'POST',
293+
path: '/path',
294+
body: { a: 'b' },
295+
headers: new Headers({ 'Content-Type': 'application/json' }),
296+
});
297+
298+
expect(request.headers.get('content-type')).toBe('application/json');
299+
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
300+
});
301+
302+
it('should not override Content-Type when headers is a Headers instance with custom type', async () => {
303+
const { request } = await widget.http.request({
304+
method: 'POST',
305+
path: '/path',
306+
body: 'raw',
307+
headers: new Headers({ 'Content-Type': 'text/plain' }),
308+
});
309+
310+
expect(request.headers.get('content-type')).toBe('text/plain');
311+
expect(request.body).toBe('raw');
312+
});
313+
314+
it('should preserve existing entries when headers is a Headers instance', async () => {
315+
const { request } = await widget.http.request({
316+
method: 'POST',
317+
path: '/path',
318+
body: { a: 'b' },
319+
headers: new Headers({
320+
Authorization: 'Bearer token',
321+
'X-Request-ID': '42',
322+
}),
323+
});
324+
325+
expect(request.headers.get('Authorization')).toBe('Bearer token');
326+
expect(request.headers.get('X-Request-ID')).toBe('42');
327+
expect(request.headers.get('Content-Type')).toBe('application/json');
328+
});
329+
237330
it('should timeout request which exceed predefined timeout limit', async () => {
238331
widget.$dependencies.fetch = jest.fn((url, request) => {
239332
return new Promise((resolve, reject) => {
@@ -426,3 +519,52 @@ describe('createWidget method with http client plugin', () => {
426519
});
427520
});
428521
});
522+
523+
describe('transformHeaders', () => {
524+
const transformer = transformHeaders();
525+
526+
it('should normalize a plain object to a Headers instance', async () => {
527+
const [result] = await transformer.transformRequest(
528+
null,
529+
{ headers: { Authorization: 'Bearer token' } },
530+
null,
531+
);
532+
533+
expect(result.headers).toBeInstanceOf(Headers);
534+
expect(result.headers.get('Authorization')).toBe('Bearer token');
535+
});
536+
537+
it('should normalize a Headers instance to a new Headers instance', async () => {
538+
const input = new Headers({ 'X-Custom': 'value' });
539+
const [result] = await transformer.transformRequest(
540+
null,
541+
{ headers: input },
542+
null,
543+
);
544+
545+
expect(result.headers).toBeInstanceOf(Headers);
546+
expect(result.headers.get('X-Custom')).toBe('value');
547+
});
548+
549+
it('should normalize null headers to an empty Headers instance', async () => {
550+
const [result] = await transformer.transformRequest(
551+
null,
552+
{ headers: null },
553+
null,
554+
);
555+
556+
expect(result.headers).toBeInstanceOf(Headers);
557+
expect([...result.headers.entries()]).toHaveLength(0);
558+
});
559+
560+
it('should preserve the response unchanged', async () => {
561+
const response = { ok: true };
562+
const [, resultResponse] = await transformer.transformRequest(
563+
null,
564+
{ headers: {} },
565+
response,
566+
);
567+
568+
expect(resultResponse).toBe(response);
569+
});
570+
});

packages/plugin-http-client/src/index.js

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { assignMissingKeys, bindWidgetToFunctions } from '@merkur/core';
22

3+
const CONTENT_TYPE_HEADER = 'Content-Type';
4+
const CONTENT_TYPE_JSON = 'application/json';
5+
36
export function setDefaultConfig(widget, newDefaultConfig) {
47
widget.$in.httpClient.defaultConfig = {
58
...widget.$in.httpClient.defaultConfig,
@@ -9,6 +12,7 @@ export function setDefaultConfig(widget, newDefaultConfig) {
912

1013
export function getDefaultTransformers(widget) {
1114
return [
15+
transformHeaders(widget),
1216
transformBody(widget),
1317
transformQuery(widget),
1418
transformTimeout(widget),
@@ -163,14 +167,25 @@ export function transformQuery() {
163167
};
164168
}
165169

170+
export function transformHeaders() {
171+
return {
172+
async transformRequest(widget, request, response) {
173+
return [
174+
{ ...request, headers: new Headers(request.headers ?? {}) },
175+
response,
176+
];
177+
},
178+
};
179+
}
180+
166181
export function transformBody() {
167182
return {
168183
async transformResponse(widget, request, response) {
169184
if (response.status !== 204 && typeof response.json === 'function') {
170185
const contentType = response.headers.get('content-type');
171186
let body = null;
172187

173-
if (contentType && contentType.includes('application/json')) {
188+
if (contentType && contentType.includes(CONTENT_TYPE_JSON)) {
174189
body = await response.json();
175190
} else {
176191
body = await response.text();
@@ -185,20 +200,24 @@ export function transformBody() {
185200
return [request, response];
186201
},
187202
async transformRequest(widget, request, response) {
188-
const { body, headers, method } = request;
203+
const { body, method } = request;
204+
const newHeaders = new Headers(request.headers);
205+
const isBodyMethod = !['GET', 'HEAD'].includes(method);
189206

190-
if (
191-
body &&
192-
(headers['Content-Type'] || headers['content-type']) ===
193-
'application/json' &&
194-
!['GET', 'HEAD'].includes(method)
195-
) {
196-
let newRequest = { ...request, body: JSON.stringify(body) };
207+
if (body && isBodyMethod) {
208+
if (!newHeaders.has(CONTENT_TYPE_HEADER)) {
209+
newHeaders.set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON);
210+
}
197211

198-
return [newRequest, response];
212+
if (newHeaders.get(CONTENT_TYPE_HEADER) === CONTENT_TYPE_JSON) {
213+
return [
214+
{ ...request, headers: newHeaders, body: JSON.stringify(body) },
215+
response,
216+
];
217+
}
199218
}
200219

201-
return [request, response];
220+
return [{ ...request, headers: newHeaders }, response];
202221
},
203222
};
204223
}

0 commit comments

Comments
 (0)