Skip to content

Commit bb0cff0

Browse files
authored
Fix: Downcase all incoming headers (#154)
Downcase all incoming headers to remove case-sensitivity issues. Previously, the Content-Type header could be duplicated if the incoming header was anything besides downcased.
1 parent ee018a7 commit bb0cff0

File tree

3 files changed

+143
-4
lines changed

3 files changed

+143
-4
lines changed

.changeset/rude-planets-kiss.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@apollo/datasource-rest': patch
3+
---
4+
5+
Addresses duplicate content-type header bug due to upper-cased headers being forwarded. This change instead maps all headers to lowercased headers.

src/RESTDataSource.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -454,14 +454,22 @@ export abstract class RESTDataSource {
454454
path: string,
455455
incomingRequest: DataSourceRequest = {},
456456
): Promise<DataSourceFetchResult<TResult>> {
457+
const downcasedHeaders: Record<string, string> = {};
458+
if (incomingRequest.headers) {
459+
// map incoming headers to lower-case headers
460+
Object.entries(incomingRequest.headers).forEach(([key, value]) => {
461+
downcasedHeaders[key.toLowerCase()] = value;
462+
});
463+
}
464+
457465
const augmentedRequest: AugmentedRequest = {
458466
...incomingRequest,
459467
// guarantee params and headers objects before calling `willSendRequest` for convenience
460468
params:
461469
incomingRequest.params instanceof URLSearchParams
462470
? incomingRequest.params
463471
: this.urlSearchParamsFromRecord(incomingRequest.params),
464-
headers: incomingRequest.headers ?? Object.create(null),
472+
headers: downcasedHeaders,
465473
};
466474
// Default to GET in the case that `fetch` is called directly with no method
467475
// provided. Our other request methods all provide one.
@@ -481,9 +489,7 @@ export abstract class RESTDataSource {
481489
if (this.shouldJSONSerializeBody(augmentedRequest.body)) {
482490
augmentedRequest.body = JSON.stringify(augmentedRequest.body);
483491
// If Content-Type header has not been previously set, set to application/json
484-
if (!augmentedRequest.headers) {
485-
augmentedRequest.headers = { 'content-type': 'application/json' };
486-
} else if (!augmentedRequest.headers['content-type']) {
492+
if (!augmentedRequest.headers['content-type']) {
487493
augmentedRequest.headers['content-type'] = 'application/json';
488494
}
489495
}

src/__tests__/RESTDataSource.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,134 @@ describe('RESTDataSource', () => {
260260
await dataSource.getFoo('1');
261261
});
262262

263+
it('contains only one Content-Type header when Content-Type already exists is application/json', async () => {
264+
const requestOptions = {
265+
headers: {
266+
'Content-Type': 'application/json',
267+
},
268+
body: { foo: 'bar' },
269+
};
270+
const dataSource = new (class extends RESTDataSource {
271+
override baseURL = 'https://api.example.com';
272+
273+
postFoo() {
274+
return this.post('foo', requestOptions);
275+
}
276+
})();
277+
278+
const spyOnHttpFetch = jest.spyOn(dataSource['httpCache'], 'fetch');
279+
280+
nock(apiUrl)
281+
.post('/foo')
282+
.reply(200, { foo: 'bar' }, { 'Content-Type': 'application/json' });
283+
284+
const data = await dataSource.postFoo();
285+
expect(spyOnHttpFetch.mock.calls[0][1]).toEqual({
286+
headers: { 'content-type': 'application/json' },
287+
body: '{"foo":"bar"}',
288+
method: 'POST',
289+
params: new URLSearchParams(),
290+
});
291+
expect(data).toEqual({ foo: 'bar' });
292+
});
293+
294+
it('converts uppercase-containing headers to lowercase', async () => {
295+
const requestOptions = {
296+
headers: {
297+
'Content-Type': 'application/json',
298+
'Test-Header': 'foobar',
299+
'ANOTHER-TEST-HEADER': 'test2',
300+
},
301+
body: { foo: 'bar' },
302+
};
303+
const dataSource = new (class extends RESTDataSource {
304+
override baseURL = 'https://api.example.com';
305+
306+
postFoo() {
307+
return this.post('foo', requestOptions);
308+
}
309+
})();
310+
311+
const spyOnHttpFetch = jest.spyOn(dataSource['httpCache'], 'fetch');
312+
313+
nock(apiUrl)
314+
.post('/foo')
315+
.reply(200, { foo: 'bar' }, { 'Content-Type': 'application/json' });
316+
317+
const data = await dataSource.postFoo();
318+
expect(spyOnHttpFetch.mock.calls[0][1]).toEqual({
319+
headers: {
320+
'content-type': 'application/json',
321+
'test-header': 'foobar',
322+
'another-test-header': 'test2',
323+
},
324+
body: '{"foo":"bar"}',
325+
method: 'POST',
326+
params: new URLSearchParams(),
327+
});
328+
expect(data).toEqual({ foo: 'bar' });
329+
});
330+
331+
it('adds an `application/json` content-type header when none is present', async () => {
332+
const requestOptions = {
333+
body: { foo: 'bar' },
334+
};
335+
const dataSource = new (class extends RESTDataSource {
336+
override baseURL = 'https://api.example.com';
337+
338+
postFoo() {
339+
return this.post('foo', requestOptions);
340+
}
341+
})();
342+
343+
const spyOnHttpFetch = jest.spyOn(dataSource['httpCache'], 'fetch');
344+
345+
nock(apiUrl)
346+
.post('/foo')
347+
.reply(200, { foo: 'bar' }, { 'Content-Type': 'application/json' });
348+
349+
const data = await dataSource.postFoo();
350+
expect(spyOnHttpFetch.mock.calls[0][1]).toEqual({
351+
headers: {
352+
'content-type': 'application/json',
353+
},
354+
body: '{"foo":"bar"}',
355+
method: 'POST',
356+
params: new URLSearchParams(),
357+
});
358+
expect(data).toEqual({ foo: 'bar' });
359+
});
360+
361+
it('adds an `application/json` content header when no headers are passed in', async () => {
362+
const requestOptions = {
363+
body: { foo: 'bar' },
364+
};
365+
const dataSource = new (class extends RESTDataSource {
366+
override baseURL = 'https://api.example.com';
367+
368+
postFoo() {
369+
return this.post('foo', requestOptions);
370+
}
371+
})();
372+
373+
const spyOnHttpFetch = jest.spyOn(dataSource['httpCache'], 'fetch');
374+
375+
nock(apiUrl)
376+
.post('/foo')
377+
.reply(200, { foo: 'bar' }, { 'Content-Type': 'application/json' });
378+
379+
const data = await dataSource.postFoo();
380+
expect(spyOnHttpFetch.mock.calls[0][1]).toEqual({
381+
headers: {
382+
'content-type': 'application/json',
383+
},
384+
body: '{"foo":"bar"}',
385+
method: 'POST',
386+
params: new URLSearchParams(),
387+
});
388+
expect(data).toEqual({ foo: 'bar' });
389+
});
390+
263391
it('serializes a request body that is an object as JSON', async () => {
264392
const expectedFoo = { foo: 'bar' };
265393
const dataSource = new (class extends RESTDataSource {

0 commit comments

Comments
 (0)