Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Response headers/raw headers to more closely match Node's functionality. #1564

Merged
merged 4 commits into from
Jun 8, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
165 changes: 145 additions & 20 deletions lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,23 +186,19 @@ function stringifyRequest(options, body) {
}

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

function contentEncoding(headers, encoder) {
const contentEncoding = _.get(headers, 'content-encoding')
const contentEncoding = headers['content-encoding']
return contentEncoding === encoder
}

function isJSONContent(headers) {
let contentType = _.get(headers, 'content-type')
if (Array.isArray(contentType)) {
contentType = contentType[0]
}
contentType = (contentType || '').toLocaleLowerCase()

return contentType === 'application/json'
// https://tools.ietf.org/html/rfc8259
const contentType = (headers['content-type'] || '').toLowerCase()
return contentType.startsWith('application/json')
paulmelnikow marked this conversation as resolved.
Show resolved Hide resolved
}

const headersFieldNamesToLowerCase = function(headers) {
Expand Down Expand Up @@ -236,26 +232,139 @@ const headersFieldsArrayToLowerCase = function(headers) {
)
}

/**
* Converts the various accepted formats of headers into a flat array representing "raw headers".
*
* Nock allows headers to be provided as a raw array, a plain object, or a Map.
*
* While all the header names are expected to be strings, the values are left intact as they can
* be functions, strings, or arrays of strings.
*
* https://nodejs.org/api/http.html#http_message_rawheaders
*/
const headersInputToRawArray = function(headers) {
if (headers === undefined) {
return []
}

if (Array.isArray(headers)) {
// If the input is an array, assume it's already in the raw format and simply return a copy
// but throw an error if there aren't an even number of items in the array
if (headers.length % 2) {
throw new Error(
`Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]`
)
}
return [...headers]
}

// [].concat(...) is used instead of Array.flat until v11 is the minimum Node version
if (_.isMap(headers)) {
return [].concat(...Array.from(headers, ([k, v]) => [k.toString(), v]))
}

if (_.isPlainObject(headers)) {
return [].concat(...Object.entries(headers))
}

throw new Error(
`Headers must be provided as an array of raw values, a Map, or a plain Object. ${headers}`
)
}

/**
* Converts an array of raw headers to an object, using the same rules as Nodes `http.IncomingMessage.headers`.
*
* Header names/keys are lower-cased.
*/
const headersArrayToObject = function(rawHeaders) {
if (!Array.isArray(rawHeaders)) {
throw Error('Expected a header array')
}

const headers = {}
const accumulator = {}

for (let i = 0, len = rawHeaders.length; i < len; i = i + 2) {
const key = rawHeaders[i].toLowerCase()
const value = rawHeaders[i + 1]
forEachHeader(rawHeaders, (value, fieldName) => {
addHeaderLine(accumulator, fieldName, value)
})

return accumulator
}

if (headers[key]) {
headers[key] = Array.isArray(headers[key]) ? headers[key] : [headers[key]]
headers[key].push(value)
const noDuplicatesHeaders = new Set([
'age',
'authorization',
'content-length',
'content-type',
'etag',
'expires',
'from',
'host',
'if-modified-since',
'if-unmodified-since',
'last-modified',
'location',
'max-forwards',
'proxy-authorization',
'referer',
'retry-after',
'user-agent',
])

/**
* Set key/value data in accordance with Node's logic for folding duplicate headers.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From reading this, it's not clear which parts of the code below is related to specific behaviors in Node. Enumerating the specifics is important here. Could you paste in some of the notes from your PR message, and include the link?

*
* The `value` param should be a function, string, or array of strings.
*
* Node's docs and source:
* https://nodejs.org/api/http.html#http_message_headers
* https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245
*
* Header names are lower-cased.
* Duplicates in raw headers are handled in the following ways, depending on the header name:
* - Duplicates of field names listed in `noDuplicatesHeaders` (above) are discarded.
* - `set-cookie` is always an array. Duplicates are added to the array.
* - For duplicate `cookie` headers, the values are joined together with '; '.
* - For all other headers, the values are joined together with ', '.
*
* Node's implementation is larger because it highly optimizes for not having to call `toLowerCase()`.
* We've opted to always call `toLowerCase` in exchange for a more concise function.
*
* While Node has the luxury of knowing `value` is always a string, we do an extra step of coercion at the top.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for these doc additions 🙌

*/
const addHeaderLine = function(headers, name, value) {
let values // code below expects `values` to be an array of strings
if (typeof value === 'function') {
// Function values are evaluated towards the end of the response, before that we use a placeholder
// string just to designate that the header exists. Useful when `Content-Type` is set with a function.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it would be better to use a boolean true to represent this condition. Or better yet, true in place of an array.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

values needs to be an array of strings to behave.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which part does? I think it's a good idea not to evaluate the function values twice, though it makes me nervous to pass around objects with half-evaluated header values.

I haven't followed exactly how this is used, though some of it seems related to checking whether other headers are present. Would it work to pass around the list of keys?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Augh I see it's really involved and complicated. Would like this to be cleaner but it can be left for another day!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, the functions are called at most once and we have a test to assert that.

addHeaderLine is a private helper function for headersArrayToObject, which is called in two places.

First in RequestOverrider.end, in the case all the functions have already been evaluated so value will never be a function.

The second place headersArrayToObject is called is in Interceptor.reply where headers are temporarily assembled to aid in other logic between the Interceptor and the RequestOverrider.
I left a comment there to explain why this is done:

nock/lib/interceptor.js

Lines 112 to 119 in 89f3ebf

// Prepare the headers temporarily so we can make best guesses about content-encoding and content-type
// below as well as while the response is being processed in RequestOverrider.end().
// Including all the default headers is safe for our purposes because of the specific headers we introspect.
// A more thoughtful process is used to merge the default headers when the response headers are finally computed.
this.headers = common.headersArrayToObject([
...this.rawHeaders,
...this.scope._defaultReplyHeaders,
])

All of that is roughly the same as it was before. Previously, in Interceptor.reply, the headers would get grouped and the ones that happened to be functions where just left as functions in the headers object. The change I created here was caused by enforcing a parity with the headers object and what Node would create. Node's http.IncomingMessage.headers object always has values of strings (expect set-cookie which is an array of strings). For the case of values that are functions, I had to make a decision on how to handle those given it's not a feature of Node. I didn't want to skip them because there are cases, eg Content-Encoding, where the existence of the field name made a difference. I chose to use the functions name as I thought it might help users when debugging.
Passing around a list of header field names wouldn't work as there are sometimes, eg Content-Type, where the value in introspected. Now in those cases, if the value is a function, it's not compared correctly. But that's a shortcoming of the current code, and probably one everyone is ok living with as the alternative would be to evaluate the header functions before we have a response.

values = [value.name]
} else if (Array.isArray(value)) {
values = value.map(String)
} else {
values = [String(value)]
}

const key = name.toLowerCase()
if (key === 'set-cookie') {
// Array header -- only Set-Cookie at the moment
if (headers['set-cookie'] === undefined) {
headers['set-cookie'] = values
} else {
headers[key] = value
headers['set-cookie'].push(...values)
}
} else if (noDuplicatesHeaders.has(key)) {
if (headers[key] === undefined) {
// Drop duplicates
headers[key] = values[0]
}
} else {
if (headers[key] !== undefined) {
values = [headers[key], ...values]
}
}

return headers
const separator = key === 'cookie' ? '; ' : ', '
headers[key] = values.join(separator)
}
}

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

/**
* Utility for iterating over a raw headers array.
*
* The callback is called with:
* - The header value. string, array of strings, or a function
* - The header field name. string
* - Index of the header field in the raw header array.
*/
const forEachHeader = function(rawHeaders, callback) {
for (let i = 0; i < rawHeaders.length; i += 2) {
callback(rawHeaders[i + 1], rawHeaders[i], i)
}
}

function percentDecode(str) {
try {
return decodeURIComponent(str.replace(/\+/g, ' '))
Expand Down Expand Up @@ -391,7 +514,9 @@ exports.isJSONContent = isJSONContent
exports.headersFieldNamesToLowerCase = headersFieldNamesToLowerCase
exports.headersFieldsArrayToLowerCase = headersFieldsArrayToLowerCase
exports.headersArrayToObject = headersArrayToObject
exports.headersInputToRawArray = headersInputToRawArray
exports.deleteHeadersField = deleteHeadersField
exports.forEachHeader = forEachHeader
exports.percentEncode = percentEncode
exports.percentDecode = percentDecode
exports.matchStringOrRegexp = matchStringOrRegexp
Expand Down
61 changes: 23 additions & 38 deletions lib/interceptor.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict'

const mixin = require('./mixin')
const matchBody = require('./match_body')
const common = require('./common')
const _ = require('lodash')
Expand Down Expand Up @@ -103,64 +102,50 @@ Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {

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

// If needed, convert rawHeaders from Array to Object.
let headers = Array.isArray(rawHeaders)
? common.headersArrayToObject(rawHeaders)
: rawHeaders

if (this.scope._defaultReplyHeaders) {
headers = headers || {}
// Because of this, this.rawHeaders gets lower-cased versions of the `rawHeaders` param.
// https://github.com/nock/nock/issues/1553
headers = common.headersFieldNamesToLowerCase(headers)
headers = mixin(this.scope._defaultReplyHeaders, headers)
}
this.rawHeaders = common.headersInputToRawArray(rawHeaders)

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

if (headers !== undefined) {
this.rawHeaders = []

for (const key in headers) {
this.rawHeaders.push(key, headers[key])
}

// We use lower-case headers throughout Nock.
this.headers = common.headersFieldNamesToLowerCase(headers)

debug('reply.headers:', this.headers)
debug('reply.rawHeaders:', this.rawHeaders)
}
// Prepare the headers temporarily so we can make best guesses about content-encoding and content-type
// below as well as while the response is being processed in RequestOverrider.end().
// Including all the default headers is safe for our purposes because of the specific headers we introspect.
// A more thoughtful process is used to merge the default headers when the response headers are finally computed.
this.headers = common.headersArrayToObject(
this.rawHeaders.concat(this.scope._defaultReplyHeaders)
)

// If the content is not encoded we may need to transform the response body.
// Otherwise we leave it as it is.
if (
body &&
typeof body !== 'string' &&
typeof body !== 'function' &&
!Buffer.isBuffer(body) &&
!common.isStream(body) &&
!common.isContentEncoded(this.headers)
) {
try {
body = stringify(body)
if (!this.headers) {
this.headers = {}
}
if (!this.headers['content-type']) {
this.headers['content-type'] = 'application/json'
}
if (this.scope.contentLen) {
this.headers['content-length'] = body.length
}
} catch (err) {
throw new Error('Error encoding response body into JSON')
}

if (!this.headers['content-type']) {
// https://tools.ietf.org/html/rfc7231#section-3.1.1.5
this.rawHeaders.push('Content-Type', 'application/json')
}

if (this.scope.contentLen) {
// https://tools.ietf.org/html/rfc7230#section-3.3.2
this.rawHeaders.push('Content-Length', body.length)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}
}

debug('reply.headers:', this.headers)
debug('reply.rawHeaders:', this.rawHeaders)

this.body = body

this.scope.add(this._key, this, this.scope, this.scopeOptions)
Expand Down
12 changes: 0 additions & 12 deletions lib/mixin.js

This file was deleted.

2 changes: 1 addition & 1 deletion lib/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const getBodyFromChunks = function(chunks, headers) {
// If we have headers and there is content-encoding it means that
// the body shouldn't be merged but instead persisted as an array
// of hex strings so that the responses can be mocked one by one.
if (common.isContentEncoded(headers)) {
if (headers && common.isContentEncoded(headers)) {
return {
body: _.map(chunks, chunk => chunk.toString('hex')),
}
Expand Down