Skip to content

Commit db38422

Browse files
authored
fix: propagate user headers between internal api requests (#1049)
* propagate user headers from server renders * fix tests * dynamically import headers * log propagated header keys * add test cases * import headers inline
1 parent 60de2c5 commit db38422

File tree

3 files changed

+106
-19
lines changed

3 files changed

+106
-19
lines changed

src/utils/request/__tests__/request.node.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
import { headers } from 'next/headers';
2+
13
import getConfigValue from '@/utils/config/get-config-value';
24

35
import request from '../request';
46
jest.mock('@/utils/config/get-config-value');
7+
jest.mock('next/headers', () => ({
8+
headers: jest.fn().mockReturnValue({
9+
entries: jest.fn().mockReturnValue([]),
10+
}),
11+
}));
512

613
describe('request on node env', () => {
714
afterEach(() => {
@@ -24,7 +31,41 @@ describe('request on node env', () => {
2431
await request(url, options);
2532
expect(fetch).toHaveBeenCalledWith(`http://127.0.0.1:${port}` + url, {
2633
cache: 'no-cache',
34+
headers: {},
2735
...options,
2836
});
2937
});
38+
it('should merge user headers with call headers, with call headers taking precedence', async () => {
39+
const url = '/api/data';
40+
const mockHeaders = jest.fn().mockReturnValue({
41+
entries: jest.fn().mockReturnValue([
42+
['x-user-id', 'user123'],
43+
['authorization', 'Bearer user-token'],
44+
]),
45+
});
46+
(headers as jest.MockedFunction<typeof headers>).mockReturnValue(
47+
mockHeaders() as any
48+
);
49+
50+
const options = {
51+
method: 'POST',
52+
headers: {
53+
'content-type': 'application/json',
54+
authorization: 'Bearer override-token',
55+
},
56+
};
57+
58+
const port = await getConfigValue('CADENCE_WEB_PORT');
59+
await request(url, options);
60+
61+
expect(fetch).toHaveBeenCalledWith(`http://127.0.0.1:${port}` + url, {
62+
cache: 'no-cache',
63+
headers: {
64+
'x-user-id': 'user123',
65+
authorization: 'Bearer override-token', // Call header overrides user header
66+
'content-type': 'application/json',
67+
},
68+
method: 'POST',
69+
});
70+
});
3071
});

src/utils/request/__tests__/request.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1+
import { headers } from 'next/headers';
2+
13
import request from '../request';
24
import { RequestError } from '../request-error';
35

6+
jest.mock('next/headers', () => ({
7+
headers: jest.fn().mockReturnValue({
8+
entries: jest.fn().mockReturnValue([
9+
['x-user-id', 'user123'],
10+
['authorization', 'Bearer user-token'],
11+
]),
12+
}),
13+
}));
14+
415
describe('request on browser env', () => {
516
afterEach(() => {
617
const mockedFetch = global.fetch as jest.MockedFunction<
@@ -21,14 +32,42 @@ describe('request on browser env', () => {
2132
const url = 'http://example.com';
2233
const options = { method: 'GET' };
2334
await request(url, options);
24-
expect(fetch).toHaveBeenCalledWith(url, { cache: 'no-cache', ...options });
35+
expect(fetch).toHaveBeenCalledWith(url, {
36+
cache: 'no-cache',
37+
headers: {},
38+
...options,
39+
});
2540
});
2641

2742
it('should call fetch with relative URL on client and no-cache option', async () => {
2843
const url = '/api/data';
2944
const options = { method: 'POST' };
3045
await request(url, options);
31-
expect(fetch).toHaveBeenCalledWith(url, { cache: 'no-cache', ...options });
46+
expect(fetch).toHaveBeenCalledWith(url, {
47+
cache: 'no-cache',
48+
headers: {},
49+
...options,
50+
});
51+
});
52+
53+
it('should not call headers() or use user headers in client environment', async () => {
54+
const url = '/api/data';
55+
const options = {
56+
method: 'POST',
57+
headers: { 'content-type': 'application/json' },
58+
};
59+
60+
await request(url, options);
61+
62+
// Verify headers() was never called in browser environment
63+
expect(headers).not.toHaveBeenCalled();
64+
65+
// Verify only the provided headers are used, not user headers
66+
expect(fetch).toHaveBeenCalledWith(url, {
67+
cache: 'no-cache',
68+
headers: { 'content-type': 'application/json' },
69+
method: 'POST',
70+
});
3271
});
3372

3473
it('should return error if request.ok is false', async () => {

src/utils/request/request.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,35 @@ export default async function request(
77
options?: RequestInit
88
): Promise<Response> {
99
let absoluteUrl = url;
10+
let userHeaders = {};
1011
const isRelativeUrl = url.startsWith('/');
1112
const isOnServer = typeof window === 'undefined';
1213
if (isOnServer && isRelativeUrl) {
1314
const port = await getConfigValue('CADENCE_WEB_PORT');
1415
absoluteUrl = `http://127.0.0.1:${port}${url}`;
16+
// propagate user headers from browser to server API calls
17+
userHeaders = Object.fromEntries(
18+
await (await import('next/headers')).headers().entries()
19+
);
1520
}
16-
17-
return fetch(absoluteUrl, { cache: 'no-cache', ...(options || {}) }).then(
18-
async (res) => {
19-
if (!res.ok) {
20-
const error = await res.json();
21-
throw new RequestError(
22-
error.message,
23-
url,
24-
res.status,
25-
error.validationErrors,
26-
{
27-
cause: error.cause,
28-
}
29-
);
30-
}
31-
return res;
21+
const requestHeaders = { ...userHeaders, ...(options?.headers || {}) };
22+
return fetch(absoluteUrl, {
23+
cache: 'no-cache',
24+
...(options || {}),
25+
headers: requestHeaders,
26+
}).then(async (res) => {
27+
if (!res.ok) {
28+
const error = await res.json();
29+
throw new RequestError(
30+
error.message,
31+
url,
32+
res.status,
33+
error.validationErrors,
34+
{
35+
cause: error.cause,
36+
}
37+
);
3238
}
33-
);
39+
return res;
40+
});
3441
}

0 commit comments

Comments
 (0)