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

Update/clarify interceptor.reply param juggling. #1520

Merged
merged 21 commits into from May 20, 2019
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
26 changes: 24 additions & 2 deletions lib/common.js
Expand Up @@ -40,7 +40,7 @@ const normalizeRequestOptions = function(options) {
* from its utf8 representation.
*
* TODO: Reverse the semantics of this method and refactor calling code
* accordingly. We've inadvertantly gotten it flipped.
* accordingly. We've inadvertently gotten it flipped.
*
* @param {Object} buffer - a Buffer object
*/
Expand Down Expand Up @@ -245,7 +245,7 @@ const headersArrayToObject = function(rawHeaders) {
const value = rawHeaders[i + 1]

if (headers[key]) {
headers[key] = _.isArray(headers[key]) ? headers[key] : [headers[key]]
headers[key] = _.castArray(headers[key])
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
headers[key].push(value)
} else {
headers[key] = value
Expand Down Expand Up @@ -377,6 +377,27 @@ function isStream(obj) {
)
}

/**
* Casts a provided status code value to an integer.
*
* Raises an error if invalid.
* Note while RFC 7231 states status codes are only three digit integers,
* we don't bother to enforce that here.
*/
function statusCodeInt(input) {
if (Number.isInteger(input)) {
return input
}

const int = Number.parseInt(input)
Copy link
Member

Choose a reason for hiding this comment

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

I appreciate your thoroughness!

Is there any reason we need to accept anything other than an integer here?

If we don't need to, I'd rather not. It's more behavior to test and more code to maintain.


if (Number.isNaN(int)) {
throw new Error(`Invalid ${typeof input} value for status code`)
}

return int
}

exports.normalizeRequestOptions = normalizeRequestOptions
exports.isUtf8Representable = isUtf8Representable
exports.overrideRequests = overrideRequests
Expand All @@ -394,3 +415,4 @@ exports.percentDecode = percentDecode
exports.matchStringOrRegexp = matchStringOrRegexp
exports.formatQueryValue = formatQueryValue
exports.isStream = isStream
exports.statusCodeInt = statusCodeInt
46 changes: 29 additions & 17 deletions lib/interceptor.js
Expand Up @@ -78,13 +78,26 @@ Interceptor.prototype.replyWithError = function replyWithError(errorMessage) {
}

Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {
if (arguments.length <= 2 && _.isFunction(statusCode)) {
body = statusCode
statusCode = 200
// support the format of only passing in a callback
if (_.isFunction(statusCode)) {
if (arguments.length > 1) {
// it's not very Javascript-y to throw an error for this kind of thing, but because
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// it's not very Javascript-y to throw an error for this kind of thing, but because
// It's not very Javascript-y to throw an error for extra args to a function, but because

// of legacy behavior, this error was added to reduce confusion for those migrating.
throw Error(
'Invalid arguments. When providing a function for the first argument, .reply does not accept other arguments.'
)
}
this.statusCode = null
this.callback = statusCode
this.fullResponseFromCallback = true
Copy link
Member

Choose a reason for hiding this comment

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

👍

} else {
this.statusCode = common.statusCodeInt(statusCode)
if (_.isFunction(body)) {
this.callback = body
body = null
}
}

this.statusCode = statusCode

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

// If needed, convert rawHeaders from Array to Object.
Expand All @@ -94,6 +107,7 @@ Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {

if (this.scope._defaultReplyHeaders) {
headers = headers || {}
// because of this, this.rawHeaders gets lower-cased versions of the `rawHeaders` param. is that a bug?
Copy link
Member

Choose a reason for hiding this comment

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

If I understand your question correctly, no, it's not a bug, and there are tests ensuring that this behavior is followed. The field names of HTTP headers are case-insensitive, and nock lowercases everything for matching.

Copy link
Member Author

Choose a reason for hiding this comment

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

The headers object is meant to be case-insensitive by always holding the lowercase version of the keys, however, the case-sensitivity of rawHeaders is inconsistent.
Usually they're kept as the provided value, but sometimes (like here) they get lowercased first.
I'm not positive what the intent is, I've just added a couple comments about the inconsistency when I come across them.

Copy link
Member

Choose a reason for hiding this comment

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

Ah I see. Makes sense: you're saying we want rawHeaders to keep their original case.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't use rawHeaders in any of my projects and the README never mentions them. So I'm not sure what we want.
If the intention is to mimic http.IncomingMessage.rawHeaders then it seems the current implementation is buggy.

The raw request/response headers list exactly as they were received.
...
Header names are not lowercased, and duplicates are not merged.
...
For example, the previous message header object might have a rawHeaders list like the following:

[ 'ConTent-Length', '123456',
  'content-LENGTH', '123',
  'content-type', 'text/plain',
  'CONNECTION', 'keep-alive',
  'Host', 'mysite.com',
  'accepT', '*/*' ]

This topic should probably be moved to its own issue if we consider it a bug.

Copy link
Member

Choose a reason for hiding this comment

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

If the intention is to mimic http.IncomingMessage.rawHeaders then it seems the current implementation is buggy.

Agreed, let's open a new issue. It sounds like it may require some thinking through.

We do want the rawHeaders on a nock-produced IncomingMessage to match what they would be on a real one, and ideally it should be possible to establish through nock whatever casing the caller might want.

Interestingly, I don't think that is the format of our rawHeaders object. I think at least one of our tests for rawHeaders is showing an array for the duplicated headers:

[
  'content-length': ['123456', '123']
]

mastermatt marked this conversation as resolved.
Show resolved Hide resolved
headers = common.headersFieldNamesToLowerCase(headers)
headers = mixin(this.scope._defaultReplyHeaders, headers)
}
Expand All @@ -107,8 +121,7 @@ Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {
this.rawHeaders = []

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

// We use lower-case headers throughout Nock.
Expand All @@ -120,9 +133,8 @@ Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {

// If the content is not encoded we may need to transform the response body.
// Otherwise we leave it as it is.
if (!common.isContentEncoded(this.headers)) {
if (body && !common.isContentEncoded(this.headers)) {
if (
body &&
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
typeof body !== 'string' &&
typeof body !== 'function' &&
!Buffer.isBuffer(body) &&
Expand Down Expand Up @@ -419,7 +431,7 @@ Interceptor.prototype.matchAddress = function matchAddress(options) {

Interceptor.prototype.filteringPath = function filteringPath() {
if (_.isFunction(arguments[0])) {
this.scope.transformFunction = arguments[0]
this.scope.transformPathFunction = arguments[0]
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
}
return this
}
Expand Down Expand Up @@ -454,7 +466,7 @@ Interceptor.prototype.basicAuth = function basicAuth(options) {
/**
* Set query strings for the interceptor
* @name query
* @param Object Object of query string name,values (accepts regexp values)
* @param queries Object of query string name,values (accepts regexp values)
* @public
* @example
* // Will match 'http://zombo.com/?q=t'
Expand Down Expand Up @@ -496,7 +508,7 @@ Interceptor.prototype.query = function query(queries) {
/**
* Set number of times will repeat the interceptor
* @name times
* @param Integer Number of times to repeat (should be > 0)
* @param newCounter Number of times to repeat (should be > 0)
* @public
* @example
* // Will repeat mock 5 times for same king of request
Expand Down Expand Up @@ -554,7 +566,7 @@ Interceptor.prototype.thrice = function thrice() {
* @param {(integer|object)} opts - Number of milliseconds to wait, or an object
* @param {integer} [opts.head] - Number of milliseconds to wait before response is sent
* @param {integer} [opts.body] - Number of milliseconds to wait before response body is sent
* @return {interceptor} - the current interceptor for chaining
* @return {Interceptor} - the current interceptor for chaining
*/
Interceptor.prototype.delay = function delay(opts) {
let headDelay = 0
Expand All @@ -565,7 +577,7 @@ Interceptor.prototype.delay = function delay(opts) {
headDelay = opts.head || 0
bodyDelay = opts.body || 0
} else {
throw new Error(`Unexpected input opts${opts}`)
throw new Error(`Unexpected input opts ${opts}`)
}

return this.delayConnection(headDelay).delayBody(bodyDelay)
Expand All @@ -575,7 +587,7 @@ Interceptor.prototype.delay = function delay(opts) {
* Delay the response body by a certain number of ms.
*
* @param {integer} ms - Number of milliseconds to wait before response is sent
* @return {interceptor} - the current interceptor for chaining
* @return {Interceptor} - the current interceptor for chaining
*/
Interceptor.prototype.delayBody = function delayBody(ms) {
this.delayInMs += ms
Expand All @@ -586,7 +598,7 @@ Interceptor.prototype.delayBody = function delayBody(ms) {
* Delay the connection by a certain number of ms.
*
* @param {integer} ms - Number of milliseconds to wait
* @return {interceptor} - the current interceptor for chaining
* @return {Interceptor} - the current interceptor for chaining
*/
Interceptor.prototype.delayConnection = function delayConnection(ms) {
this.delayConnectionInMs += ms
Expand All @@ -601,7 +613,7 @@ Interceptor.prototype.getTotalDelay = function getTotalDelay() {
* Make the socket idle for a certain number of ms (simulated).
*
* @param {integer} ms - Number of milliseconds to wait
* @return {interceptor} - the current interceptor for chaining
* @return {Interceptor} - the current interceptor for chaining
*/
Interceptor.prototype.socketDelay = function socketDelay(ms) {
this.socketDelayInMs = ms
Expand Down
86 changes: 52 additions & 34 deletions lib/request_overrider.js
Expand Up @@ -286,15 +286,13 @@ function RequestOverrider(req, options, interceptors, remove, cb) {
timers.setTimeout(emitError, interceptor.getTotalDelay(), error)
return
}
// TODO-coverage: Either add a test or remove `|| 200` if it's not reachable.
response.statusCode = Number(interceptor.statusCode) || 200

// Clone headers/rawHeaders to not override them when evaluating later
response.headers = _.extend({}, interceptor.headers)
response.rawHeaders = (interceptor.rawHeaders || []).slice()
debug('response.rawHeaders:', response.rawHeaders)

if (typeof interceptor.body === 'function') {
if (interceptor.callback) {
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
if (requestBody && common.isJSONContent(req.headers)) {
if (common.contentEncoding(req.headers, 'gzip')) {
requestBody = String(zlib.gunzipSync(Buffer.from(requestBody, 'hex')))
Expand All @@ -308,15 +306,15 @@ function RequestOverrider(req, options, interceptors, remove, cb) {
}

// In case we are waiting for a callback
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
if (interceptor.body.length === 3) {
return interceptor.body(
if (interceptor.callback.length === 3) {
return interceptor.callback(
options.path,
requestBody || '',
requestBody,
continueWithResponseBody
)
}

responseBody = interceptor.body(options.path, requestBody) || ''
responseBody = interceptor.callback(options.path, requestBody)
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
} else {
// If the content is encoded we know that the response body *must* be an array
// of response buffers which should be mocked one by one.
Expand All @@ -336,12 +334,7 @@ function RequestOverrider(req, options, interceptors, remove, cb) {
return
}

let buffers = interceptor.body
if (!_.isArray(buffers)) {
buffers = [buffers]
}

responseBuffers = _.map(buffers, function(buffer) {
responseBuffers = _.castArray(interceptor.body).map(function(buffer) {
return Buffer.from(buffer, 'hex')
})
} else {
Expand Down Expand Up @@ -387,34 +380,23 @@ function RequestOverrider(req, options, interceptors, remove, cb) {
if (err) {
response.statusCode = 500
responseBody = err.stack
} else if (interceptor.fullResponseFromCallback) {
try {
responseBody = parseResponseFromCallbackResult(response, responseBody)
} catch (err) {
emitError(err)
return
}
} else {
response.statusCode = interceptor.statusCode // already cast to int
}

// Transform the response body if it exists (it may not exist
// if we have `responseBuffers` instead)

if (responseBody) {
if (!responseBuffers) {
debug('transform the response body')

if (Array.isArray(responseBody)) {
debug('response body is array: %j', responseBody)

// TODO-coverage: else, throw an error. Add a test.
if (!isNaN(Number(responseBody[0]))) {
response.statusCode = Number(responseBody[0])
}

if (responseBody.length >= 2 && responseBody.length <= 3) {
debug('new headers: %j', responseBody[2])
_.assign(response.headers, responseBody[2] || {})
debug('response.headers after: %j', response.headers)
responseBody = responseBody[1]

Object.keys(response.headers).forEach(function(key) {
response.rawHeaders.push(key, response.headers[key])
})
}
}

if (interceptor.delayInMs) {
debug(
'delaying the response for',
Expand Down Expand Up @@ -573,4 +555,40 @@ function RequestOverrider(req, options, interceptors, remove, cb) {
return req
}

function parseResponseFromCallbackResult(response, callbackResult) {
debug('full response from callback result: %j', callbackResult)

if (!Array.isArray(callbackResult)) {
throw Error('A single function provided to .reply MUST return an array')
}

if (callbackResult.length > 3) {
throw Error(
'The array returned from the .reply callback contains too many values'
)
}

const [status, body, headers] = callbackResult
mastermatt marked this conversation as resolved.
Show resolved Hide resolved

response.statusCode = common.statusCodeInt(status)

if (headers) {
debug('new headers: %j', headers)

const rawHeaders = Array.isArray(headers)
? headers
: _.flatten(Object.entries(headers))
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
response.rawHeaders.push(...rawHeaders)

const castHeaders = Array.isArray(headers)
? common.headersArrayToObject(headers)
: common.headersFieldNamesToLowerCase(headers)

_.assign(response.headers, castHeaders)
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
debug('response.headers after: %j', response.headers)
}

return body || ''
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
}

module.exports = RequestOverrider
5 changes: 3 additions & 2 deletions lib/scope.js
Expand Up @@ -218,6 +218,7 @@ Scope.prototype.matchHeader = function matchHeader(name, value) {
}

Scope.prototype.defaultReplyHeaders = function defaultReplyHeaders(headers) {
// Because these are lower-cased, res.rawHeaders can have the wrong keys. bug?
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
this._defaultReplyHeaders = common.headersFieldNamesToLowerCase(headers)
return this
}
Expand Down Expand Up @@ -333,7 +334,7 @@ function define(nockDefs) {
options.badheaders = badheaders

// Response is not always JSON as it could be a string or binary data or
// even an array of binary buffers (e.g. when content is enconded)
// even an array of binary buffers (e.g. when content is encoded)
let response
if (!nockDef.response) {
response = ''
Expand All @@ -348,7 +349,7 @@ function define(nockDefs) {
let nock
// TODO-coverage: Find out what `body === '*'` means. Add a comment here
// explaining it, and a test case if necessary. Also, update the readme
// example of `filteringRequestBody()` beacuse it doesn't explain what the
// example of `filteringRequestBody()` because it doesn't explain what the
// `*` really means.
if (body === '*') {
nock = startScope(nscope, options)
Expand Down
22 changes: 22 additions & 0 deletions tests/test_common.js
Expand Up @@ -337,3 +337,25 @@ test('headersArrayToObject', function(t) {

t.end()
})

test('statusCodeInt casts valid inputs', t => {
t.equal(common.statusCodeInt(418), 418)
t.equal(common.statusCodeInt('418'), 418)
t.equal(common.statusCodeInt('418.000'), 418)
t.end()
})

test('statusCodeInt throws for invalid inputs', t => {
t.throws(() => common.statusCodeInt(''), Error('Invalid string value'))
t.throws(() => common.statusCodeInt('foobar'), Error('Invalid string value'))
t.throws(
() => common.statusCodeInt({ foo: 'bar' }),
Error('Invalid object value')
)
t.throws(() => common.statusCodeInt(null), Error('Invalid object value'))
t.throws(
() => common.statusCodeInt(undefined),
Error('Invalid undefined value')
)
t.end()
})
5 changes: 3 additions & 2 deletions tests/test_content_encoding.js
Expand Up @@ -13,12 +13,13 @@ test('accepts gzipped content', async t => {

nock('http://example.test')
.get('/foo')
.reply(200, compressed, {
.reply('200', compressed, {
mastermatt marked this conversation as resolved.
Show resolved Hide resolved
'X-Transfer-Length': String(compressed.length),
'Content-Length': undefined,
'Content-Encoding': 'gzip',
})
const { body } = await got('http://example.test/foo')
const { body, statusCode } = await got('http://example.test/foo')

t.equal(body, message)
t.equal(statusCode, 200)
Copy link
Member

Choose a reason for hiding this comment

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

👍

})