Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/http-client-headers-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
"@merkur/plugin-http-client": major
---

**Breaking changes in `@merkur/plugin-http-client`**

### `request.headers` is now always a `Headers` instance

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.

**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:

```js
// Before
async transformRequest(widget, request, response) {
return [{ ...request, headers: { ...request.headers, Authorization: `Bearer ${token}` } }, response];
}

// After
async transformRequest(widget, request, response) {
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${token}`);
return [{ ...request, headers }, response];
}
```

### `getDefaultTransformers` now returns four transformers

`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.

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:

```js
import { setDefaultConfig, transformHeaders, transformBody, transformQuery, transformTimeout } from '@merkur/plugin-http-client';

// Before — custom pipeline without getDefaultTransformers
setDefaultConfig(widget, {
transformers: [transformBody(), transformQuery(), transformTimeout(), myTransformer()],
});

// After — add transformHeaders as the first entry
setDefaultConfig(widget, {
transformers: [transformHeaders(), transformBody(), transformQuery(), transformTimeout(), myTransformer()],
});
```

### Default `Content-Type: application/json` for body requests

`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.

**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:

```js
// Explicitly set a non-JSON content type to override the default
await widget.http.request({
method: 'POST',
path: '/upload',
headers: { 'Content-Type': 'multipart/form-data' },
body: formData,
});
Comment thread
mjancarik marked this conversation as resolved.
```

### Polyfill required for build targets below ES2017 (ES8)

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).
3 changes: 3 additions & 0 deletions packages/plugin-graphql-client/src/__tests__/indexSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ describe('createWidget method with graphql client plugin', () => {
"query": {},
"timeout": 15000,
"transformers": [
{
"transformRequest": [Function],
},
{
"transformRequest": [Function],
"transformResponse": [Function],
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-http-cache/src/__tests__/indexSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ describe('createWidget method with http client plugin', () => {
"timeout": 15000,
"transferServerCache": false,
"transformers": [
{
"transformRequest": [Function],
},
{
"transformRequest": [Function],
"transformResponse": [Function],
Expand Down
142 changes: 142 additions & 0 deletions packages/plugin-http-client/src/__tests__/indexSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
httpClientPlugin,
setDefaultConfig,
getDefaultTransformers,
transformHeaders,
} from '../index';

describe('createWidget method with http client plugin', () => {
Expand Down Expand Up @@ -96,6 +97,9 @@ describe('createWidget method with http client plugin', () => {
{
"transformResponse": [Function],
},
{
"transformRequest": [Function],
},
{
"transformRequest": [Function],
"transformResponse": [Function],
Expand Down Expand Up @@ -234,6 +238,95 @@ describe('createWidget method with http client plugin', () => {
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
});

it('should set default Content-Type application/json for POST with body', async () => {
const { request } = await widget.http.request({
method: 'POST',
path: '/path',
body: { a: 'b' },
headers: {},
});

expect(request.headers.get('Content-Type')).toBe('application/json');
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
});

it('should not override explicit Content-Type when body is present', async () => {
const { request } = await widget.http.request({
method: 'POST',
path: '/path',
body: 'raw text',
headers: {
'Content-Type': 'text/plain',
},
});

expect(request.headers.get('Content-Type')).toBe('text/plain');
expect(request.body).toBe('raw text');
});

it('should not set default Content-Type for GET with body', async () => {
const { request } = await widget.http.request({
method: 'GET',
path: '/path',
body: { a: 'b' },
headers: {},
});

expect(request.headers.get('Content-Type')).toBeNull();
});

it('should set default Content-Type when headers is a Headers instance', async () => {
const { request } = await widget.http.request({
method: 'POST',
path: '/path',
body: { a: 'b' },
headers: new Headers(),
});

expect(request.headers.get('Content-Type')).toBe('application/json');
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
});

it('should serialize body when headers is a Headers instance with Content-Type', async () => {
const { request } = await widget.http.request({
method: 'POST',
path: '/path',
body: { a: 'b' },
headers: new Headers({ 'Content-Type': 'application/json' }),
});

expect(request.headers.get('content-type')).toBe('application/json');
expect(request.body).toMatchInlineSnapshot(`"{"a":"b"}"`);
});

it('should not override Content-Type when headers is a Headers instance with custom type', async () => {
const { request } = await widget.http.request({
method: 'POST',
path: '/path',
body: 'raw',
headers: new Headers({ 'Content-Type': 'text/plain' }),
});

expect(request.headers.get('content-type')).toBe('text/plain');
expect(request.body).toBe('raw');
});

it('should preserve existing entries when headers is a Headers instance', async () => {
const { request } = await widget.http.request({
method: 'POST',
path: '/path',
body: { a: 'b' },
headers: new Headers({
Authorization: 'Bearer token',
'X-Request-ID': '42',
}),
});

expect(request.headers.get('Authorization')).toBe('Bearer token');
expect(request.headers.get('X-Request-ID')).toBe('42');
expect(request.headers.get('Content-Type')).toBe('application/json');
});

it('should timeout request which exceed predefined timeout limit', async () => {
widget.$dependencies.fetch = jest.fn((url, request) => {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -426,3 +519,52 @@ describe('createWidget method with http client plugin', () => {
});
});
});

describe('transformHeaders', () => {
const transformer = transformHeaders();

it('should normalize a plain object to a Headers instance', async () => {
const [result] = await transformer.transformRequest(
null,
{ headers: { Authorization: 'Bearer token' } },
null,
);

expect(result.headers).toBeInstanceOf(Headers);
expect(result.headers.get('Authorization')).toBe('Bearer token');
});

it('should normalize a Headers instance to a new Headers instance', async () => {
const input = new Headers({ 'X-Custom': 'value' });
const [result] = await transformer.transformRequest(
null,
{ headers: input },
null,
);

expect(result.headers).toBeInstanceOf(Headers);
expect(result.headers.get('X-Custom')).toBe('value');
});

it('should normalize null headers to an empty Headers instance', async () => {
const [result] = await transformer.transformRequest(
null,
{ headers: null },
null,
);

expect(result.headers).toBeInstanceOf(Headers);
expect([...result.headers.entries()]).toHaveLength(0);
});

it('should preserve the response unchanged', async () => {
const response = { ok: true };
const [, resultResponse] = await transformer.transformRequest(
null,
{ headers: {} },
response,
);

expect(resultResponse).toBe(response);
});
});
41 changes: 30 additions & 11 deletions packages/plugin-http-client/src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { assignMissingKeys, bindWidgetToFunctions } from '@merkur/core';

const CONTENT_TYPE_HEADER = 'Content-Type';
const CONTENT_TYPE_JSON = 'application/json';

export function setDefaultConfig(widget, newDefaultConfig) {
widget.$in.httpClient.defaultConfig = {
...widget.$in.httpClient.defaultConfig,
Expand All @@ -9,6 +12,7 @@ export function setDefaultConfig(widget, newDefaultConfig) {

export function getDefaultTransformers(widget) {
return [
transformHeaders(widget),
transformBody(widget),
transformQuery(widget),
transformTimeout(widget),
Expand Down Expand Up @@ -163,14 +167,25 @@ export function transformQuery() {
};
}

export function transformHeaders() {
return {
async transformRequest(widget, request, response) {
return [
{ ...request, headers: new Headers(request.headers ?? {}) },
response,
];
},
};
}

export function transformBody() {
return {
async transformResponse(widget, request, response) {
if (response.status !== 204 && typeof response.json === 'function') {
const contentType = response.headers.get('content-type');
let body = null;

if (contentType && contentType.includes('application/json')) {
if (contentType && contentType.includes(CONTENT_TYPE_JSON)) {
body = await response.json();
} else {
body = await response.text();
Expand All @@ -185,20 +200,24 @@ export function transformBody() {
return [request, response];
},
async transformRequest(widget, request, response) {
const { body, headers, method } = request;
const { body, method } = request;
const newHeaders = new Headers(request.headers);
const isBodyMethod = !['GET', 'HEAD'].includes(method);

if (
body &&
(headers['Content-Type'] || headers['content-type']) ===
'application/json' &&
!['GET', 'HEAD'].includes(method)
) {
let newRequest = { ...request, body: JSON.stringify(body) };
if (body && isBodyMethod) {
if (!newHeaders.has(CONTENT_TYPE_HEADER)) {
newHeaders.set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON);
}

Comment on lines +207 to 211
return [newRequest, response];
if (newHeaders.get(CONTENT_TYPE_HEADER) === CONTENT_TYPE_JSON) {
return [
{ ...request, headers: newHeaders, body: JSON.stringify(body) },
response,
];
}
}

return [request, response];
return [{ ...request, headers: newHeaders }, response];
},
};
}
Expand Down
Loading
Loading