Skip to content

Commit b687592

Browse files
authored
feat(reply): Response headers to more closely match Node's functionality. (#1564)
* Response headers to more closely match Node. Updates the header handling in the `Interceptor` and `RequestOverrider` with the intention of mimicking the native behavior of `http.IncomingMessage.rawHeaders`. > The raw request/response headers list exactly as they were received. There are three fundamental changes in this changeset: 1) Header Input Type Previously, headers could be provided to: - `Scope.defaultReplyHeaders` as a plain object - `Interceptor.reply(status, body, headers)` as a plain object or an array of raw headers - `Interceptor.reply(() => [status, body, headers]` as a plain object Now, all three allow consistent inputs where the headers can be provided as a plain object, an array of raw headers, or a `Map`. 2) Duplicate Headers Folding This change deviates from the suggested guidelines laid out in #1553 because those guidelines didn't properly handle duplicate headers, especially when some are defined as defaults. This change was modeled to duplicate [Node's implementation](https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245) ([relevant docs](https://nodejs.org/api/http.html#http_message_headers)). It specifically lays out how duplicate headers are handled depending on the field name. In the case of default headers, they are not included on the `Response` (not even in the raw headers) if the field name exists in the reply headers (using a case-insensitive comparison). 3) Raw Headers are the Source of Truth Previously, the `Interceptor` and `RequestOverrider` mostly keep track of headers as a plain object and the array of raw headers was created by looping that object. This was the cause for inconsistencies with the final result of the raw headers. The problem with that approach is that converting raw headers to an object is a lossy process, so going backwards makes it impossible to guarantee the correct results. This change reverses that logic and now the `Interceptor` and `RequestOverrider` maintain the header data in raw arrays. All additions to headers are only added to raw headers. The plain headers object is never mutated directly, and instead is [re]created from the raw headers as needed.
1 parent 1fae9be commit b687592

File tree

10 files changed

+514
-198
lines changed

10 files changed

+514
-198
lines changed

lib/common.js

Lines changed: 145 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -186,23 +186,19 @@ function stringifyRequest(options, body) {
186186
}
187187

188188
function isContentEncoded(headers) {
189-
const contentEncoding = _.get(headers, 'content-encoding')
189+
const contentEncoding = headers['content-encoding']
190190
return _.isString(contentEncoding) && contentEncoding !== ''
191191
}
192192

193193
function contentEncoding(headers, encoder) {
194-
const contentEncoding = _.get(headers, 'content-encoding')
194+
const contentEncoding = headers['content-encoding']
195195
return contentEncoding === encoder
196196
}
197197

198198
function isJSONContent(headers) {
199-
let contentType = _.get(headers, 'content-type')
200-
if (Array.isArray(contentType)) {
201-
contentType = contentType[0]
202-
}
203-
contentType = (contentType || '').toLocaleLowerCase()
204-
205-
return contentType === 'application/json'
199+
// https://tools.ietf.org/html/rfc8259
200+
const contentType = (headers['content-type'] || '').toLowerCase()
201+
return contentType.startsWith('application/json')
206202
}
207203

208204
const headersFieldNamesToLowerCase = function(headers) {
@@ -236,26 +232,139 @@ const headersFieldsArrayToLowerCase = function(headers) {
236232
)
237233
}
238234

235+
/**
236+
* Converts the various accepted formats of headers into a flat array representing "raw headers".
237+
*
238+
* Nock allows headers to be provided as a raw array, a plain object, or a Map.
239+
*
240+
* While all the header names are expected to be strings, the values are left intact as they can
241+
* be functions, strings, or arrays of strings.
242+
*
243+
* https://nodejs.org/api/http.html#http_message_rawheaders
244+
*/
245+
const headersInputToRawArray = function(headers) {
246+
if (headers === undefined) {
247+
return []
248+
}
249+
250+
if (Array.isArray(headers)) {
251+
// If the input is an array, assume it's already in the raw format and simply return a copy
252+
// but throw an error if there aren't an even number of items in the array
253+
if (headers.length % 2) {
254+
throw new Error(
255+
`Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]`
256+
)
257+
}
258+
return [...headers]
259+
}
260+
261+
// [].concat(...) is used instead of Array.flat until v11 is the minimum Node version
262+
if (_.isMap(headers)) {
263+
return [].concat(...Array.from(headers, ([k, v]) => [k.toString(), v]))
264+
}
265+
266+
if (_.isPlainObject(headers)) {
267+
return [].concat(...Object.entries(headers))
268+
}
269+
270+
throw new Error(
271+
`Headers must be provided as an array of raw values, a Map, or a plain Object. ${headers}`
272+
)
273+
}
274+
275+
/**
276+
* Converts an array of raw headers to an object, using the same rules as Nodes `http.IncomingMessage.headers`.
277+
*
278+
* Header names/keys are lower-cased.
279+
*/
239280
const headersArrayToObject = function(rawHeaders) {
240281
if (!Array.isArray(rawHeaders)) {
241282
throw Error('Expected a header array')
242283
}
243284

244-
const headers = {}
285+
const accumulator = {}
245286

246-
for (let i = 0, len = rawHeaders.length; i < len; i = i + 2) {
247-
const key = rawHeaders[i].toLowerCase()
248-
const value = rawHeaders[i + 1]
287+
forEachHeader(rawHeaders, (value, fieldName) => {
288+
addHeaderLine(accumulator, fieldName, value)
289+
})
290+
291+
return accumulator
292+
}
249293

250-
if (headers[key]) {
251-
headers[key] = Array.isArray(headers[key]) ? headers[key] : [headers[key]]
252-
headers[key].push(value)
294+
const noDuplicatesHeaders = new Set([
295+
'age',
296+
'authorization',
297+
'content-length',
298+
'content-type',
299+
'etag',
300+
'expires',
301+
'from',
302+
'host',
303+
'if-modified-since',
304+
'if-unmodified-since',
305+
'last-modified',
306+
'location',
307+
'max-forwards',
308+
'proxy-authorization',
309+
'referer',
310+
'retry-after',
311+
'user-agent',
312+
])
313+
314+
/**
315+
* Set key/value data in accordance with Node's logic for folding duplicate headers.
316+
*
317+
* The `value` param should be a function, string, or array of strings.
318+
*
319+
* Node's docs and source:
320+
* https://nodejs.org/api/http.html#http_message_headers
321+
* https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245
322+
*
323+
* Header names are lower-cased.
324+
* Duplicates in raw headers are handled in the following ways, depending on the header name:
325+
* - Duplicates of field names listed in `noDuplicatesHeaders` (above) are discarded.
326+
* - `set-cookie` is always an array. Duplicates are added to the array.
327+
* - For duplicate `cookie` headers, the values are joined together with '; '.
328+
* - For all other headers, the values are joined together with ', '.
329+
*
330+
* Node's implementation is larger because it highly optimizes for not having to call `toLowerCase()`.
331+
* We've opted to always call `toLowerCase` in exchange for a more concise function.
332+
*
333+
* While Node has the luxury of knowing `value` is always a string, we do an extra step of coercion at the top.
334+
*/
335+
const addHeaderLine = function(headers, name, value) {
336+
let values // code below expects `values` to be an array of strings
337+
if (typeof value === 'function') {
338+
// Function values are evaluated towards the end of the response, before that we use a placeholder
339+
// string just to designate that the header exists. Useful when `Content-Type` is set with a function.
340+
values = [value.name]
341+
} else if (Array.isArray(value)) {
342+
values = value.map(String)
343+
} else {
344+
values = [String(value)]
345+
}
346+
347+
const key = name.toLowerCase()
348+
if (key === 'set-cookie') {
349+
// Array header -- only Set-Cookie at the moment
350+
if (headers['set-cookie'] === undefined) {
351+
headers['set-cookie'] = values
253352
} else {
254-
headers[key] = value
353+
headers['set-cookie'].push(...values)
354+
}
355+
} else if (noDuplicatesHeaders.has(key)) {
356+
if (headers[key] === undefined) {
357+
// Drop duplicates
358+
headers[key] = values[0]
359+
}
360+
} else {
361+
if (headers[key] !== undefined) {
362+
values = [headers[key], ...values]
255363
}
256-
}
257364

258-
return headers
365+
const separator = key === 'cookie' ? '; ' : ', '
366+
headers[key] = values.join(separator)
367+
}
259368
}
260369

261370
/**
@@ -284,11 +393,25 @@ const deleteHeadersField = function(headers, fieldNameToDelete) {
284393
if (lowerCaseFieldName === lowerCaseFieldNameToDelete) {
285394
delete headers[fieldName]
286395
// We don't stop here but continue in order to remove *all* matching field names
287-
// (even though if seen regorously there shouldn't be any)
396+
// (even though if seen rigorously there shouldn't be any)
288397
}
289398
})
290399
}
291400

401+
/**
402+
* Utility for iterating over a raw headers array.
403+
*
404+
* The callback is called with:
405+
* - The header value. string, array of strings, or a function
406+
* - The header field name. string
407+
* - Index of the header field in the raw header array.
408+
*/
409+
const forEachHeader = function(rawHeaders, callback) {
410+
for (let i = 0; i < rawHeaders.length; i += 2) {
411+
callback(rawHeaders[i + 1], rawHeaders[i], i)
412+
}
413+
}
414+
292415
function percentDecode(str) {
293416
try {
294417
return decodeURIComponent(str.replace(/\+/g, ' '))
@@ -391,7 +514,9 @@ exports.isJSONContent = isJSONContent
391514
exports.headersFieldNamesToLowerCase = headersFieldNamesToLowerCase
392515
exports.headersFieldsArrayToLowerCase = headersFieldsArrayToLowerCase
393516
exports.headersArrayToObject = headersArrayToObject
517+
exports.headersInputToRawArray = headersInputToRawArray
394518
exports.deleteHeadersField = deleteHeadersField
519+
exports.forEachHeader = forEachHeader
395520
exports.percentEncode = percentEncode
396521
exports.percentDecode = percentDecode
397522
exports.matchStringOrRegexp = matchStringOrRegexp

lib/interceptor.js

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use strict'
22

3-
const mixin = require('./mixin')
43
const matchBody = require('./match_body')
54
const common = require('./common')
65
const _ = require('lodash')
@@ -103,64 +102,50 @@ Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {
103102

104103
_.defaults(this.options, this.scope.scopeOptions)
105104

106-
// If needed, convert rawHeaders from Array to Object.
107-
let headers = Array.isArray(rawHeaders)
108-
? common.headersArrayToObject(rawHeaders)
109-
: rawHeaders
110-
111-
if (this.scope._defaultReplyHeaders) {
112-
headers = headers || {}
113-
// Because of this, this.rawHeaders gets lower-cased versions of the `rawHeaders` param.
114-
// https://github.com/nock/nock/issues/1553
115-
headers = common.headersFieldNamesToLowerCase(headers)
116-
headers = mixin(this.scope._defaultReplyHeaders, headers)
117-
}
105+
this.rawHeaders = common.headersInputToRawArray(rawHeaders)
118106

119107
if (this.scope.date) {
120-
headers = headers || {}
121-
headers['date'] = this.scope.date.toUTCString()
108+
// https://tools.ietf.org/html/rfc7231#section-7.1.1.2
109+
this.rawHeaders.push('Date', this.scope.date.toUTCString())
122110
}
123111

124-
if (headers !== undefined) {
125-
this.rawHeaders = []
126-
127-
for (const key in headers) {
128-
this.rawHeaders.push(key, headers[key])
129-
}
130-
131-
// We use lower-case headers throughout Nock.
132-
this.headers = common.headersFieldNamesToLowerCase(headers)
133-
134-
debug('reply.headers:', this.headers)
135-
debug('reply.rawHeaders:', this.rawHeaders)
136-
}
112+
// Prepare the headers temporarily so we can make best guesses about content-encoding and content-type
113+
// below as well as while the response is being processed in RequestOverrider.end().
114+
// Including all the default headers is safe for our purposes because of the specific headers we introspect.
115+
// A more thoughtful process is used to merge the default headers when the response headers are finally computed.
116+
this.headers = common.headersArrayToObject(
117+
this.rawHeaders.concat(this.scope._defaultReplyHeaders)
118+
)
137119

138120
// If the content is not encoded we may need to transform the response body.
139121
// Otherwise we leave it as it is.
140122
if (
141123
body &&
142124
typeof body !== 'string' &&
143-
typeof body !== 'function' &&
144125
!Buffer.isBuffer(body) &&
145126
!common.isStream(body) &&
146127
!common.isContentEncoded(this.headers)
147128
) {
148129
try {
149130
body = stringify(body)
150-
if (!this.headers) {
151-
this.headers = {}
152-
}
153-
if (!this.headers['content-type']) {
154-
this.headers['content-type'] = 'application/json'
155-
}
156-
if (this.scope.contentLen) {
157-
this.headers['content-length'] = body.length
158-
}
159131
} catch (err) {
160132
throw new Error('Error encoding response body into JSON')
161133
}
134+
135+
if (!this.headers['content-type']) {
136+
// https://tools.ietf.org/html/rfc7231#section-3.1.1.5
137+
this.rawHeaders.push('Content-Type', 'application/json')
138+
}
139+
140+
if (this.scope.contentLen) {
141+
// https://tools.ietf.org/html/rfc7230#section-3.3.2
142+
this.rawHeaders.push('Content-Length', body.length)
143+
}
162144
}
163145

146+
debug('reply.headers:', this.headers)
147+
debug('reply.rawHeaders:', this.rawHeaders)
148+
164149
this.body = body
165150

166151
this.scope.add(this._key, this, this.scope, this.scopeOptions)

lib/mixin.js

Lines changed: 0 additions & 12 deletions
This file was deleted.

lib/recorder.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const getBodyFromChunks = function(chunks, headers) {
4646
// If we have headers and there is content-encoding it means that
4747
// the body shouldn't be merged but instead persisted as an array
4848
// of hex strings so that the responses can be mocked one by one.
49-
if (common.isContentEncoded(headers)) {
49+
if (headers && common.isContentEncoded(headers)) {
5050
return {
5151
body: _.map(chunks, chunk => chunk.toString('hex')),
5252
}

0 commit comments

Comments
 (0)