mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-25 10:01:30 -04:00
566 lines
18 KiB
JavaScript
566 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
var mixin = require('./mixin')
|
|
, matchBody = require('./match_body')
|
|
, common = require('./common')
|
|
, _ = require('lodash')
|
|
, debug = require('debug')('nock.interceptor')
|
|
, stringify = require('json-stringify-safe')
|
|
, qs = require('qs');
|
|
|
|
var fs;
|
|
|
|
try {
|
|
fs = require('fs');
|
|
} catch (err) {
|
|
// do nothing, we're in the browser
|
|
}
|
|
|
|
module.exports = Interceptor;
|
|
|
|
function Interceptor(scope, uri, method, requestBody, interceptorOptions) {
|
|
this.scope = scope;
|
|
this.interceptorMatchHeaders = [];
|
|
this.method = method.toUpperCase();
|
|
this.uri = uri;
|
|
this._key = this.method + ' ' + scope.basePath + scope.basePathname + (typeof uri === 'string' ? '' : '/') + uri;
|
|
this.basePath = this.scope.basePath;
|
|
this.path = (typeof uri === 'string') ? scope.basePathname + uri : uri;
|
|
|
|
this.baseUri = this.method + ' ' + scope.basePath + scope.basePathname;
|
|
this.options = interceptorOptions || {};
|
|
this.counter = 1;
|
|
this._requestBody = requestBody;
|
|
|
|
// We use lower-case header field names throughout Nock.
|
|
this.reqheaders = common.headersFieldNamesToLowerCase((scope.scopeOptions && scope.scopeOptions.reqheaders) || {});
|
|
this.badheaders = common.headersFieldsArrayToLowerCase((scope.scopeOptions && scope.scopeOptions.badheaders) || []);
|
|
|
|
|
|
this.delayInMs = 0;
|
|
this.delayConnectionInMs = 0;
|
|
|
|
this.optional = false;
|
|
}
|
|
|
|
Interceptor.prototype.optionally = function optionally(value) {
|
|
// The default behaviour of optionally() with no arguments is to make the mock optional.
|
|
value = (typeof value === 'undefined') ? true : value;
|
|
|
|
this.optional = value;
|
|
|
|
return this;
|
|
}
|
|
|
|
Interceptor.prototype.replyWithError = function replyWithError(errorMessage) {
|
|
this.errorMessage = errorMessage;
|
|
|
|
_.defaults(this.options, this.scope.scopeOptions);
|
|
|
|
this.scope.add(this._key, this, this.scope, this.scopeOptions);
|
|
return this.scope;
|
|
};
|
|
|
|
Interceptor.prototype.reply = function reply(statusCode, body, rawHeaders) {
|
|
if (arguments.length <= 2 && _.isFunction(statusCode)) {
|
|
body = statusCode;
|
|
statusCode = 200;
|
|
}
|
|
|
|
this.statusCode = statusCode;
|
|
|
|
_.defaults(this.options, this.scope.scopeOptions);
|
|
|
|
// convert rawHeaders from Array to Object
|
|
var headers = common.headersArrayToObject(rawHeaders);
|
|
|
|
if (this.scope._defaultReplyHeaders) {
|
|
headers = headers || {};
|
|
headers = common.headersFieldNamesToLowerCase(headers);
|
|
headers = mixin(this.scope._defaultReplyHeaders, headers);
|
|
}
|
|
|
|
if (this.scope.date) {
|
|
headers = headers || {};
|
|
headers['date'] = this.scope.date.toUTCString();
|
|
}
|
|
|
|
if (headers !== undefined) {
|
|
this.rawHeaders = [];
|
|
|
|
// makes sure all keys in headers are in lower case
|
|
for (var key in headers) {
|
|
if (headers.hasOwnProperty(key)) {
|
|
this.rawHeaders.push(key);
|
|
this.rawHeaders.push(headers[key]);
|
|
}
|
|
}
|
|
|
|
// We use lower-case headers throughout Nock.
|
|
this.headers = common.headersFieldNamesToLowerCase(headers);
|
|
|
|
debug('reply.headers:', this.headers);
|
|
debug('reply.rawHeaders:', this.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 && typeof (body) !== 'string' &&
|
|
typeof (body) !== 'function' &&
|
|
!Buffer.isBuffer(body) &&
|
|
!common.isStream(body)) {
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
this.body = body;
|
|
|
|
this.scope.add(this._key, this, this.scope, this.scopeOptions);
|
|
return this.scope;
|
|
};
|
|
|
|
Interceptor.prototype.replyWithFile = function replyWithFile(statusCode, filePath, headers) {
|
|
if (!fs) {
|
|
throw new Error('No fs');
|
|
}
|
|
var readStream = fs.createReadStream(filePath);
|
|
readStream.pause();
|
|
this.filePath = filePath;
|
|
return this.reply(statusCode, readStream, headers);
|
|
};
|
|
|
|
// Also match request headers
|
|
// https://github.com/pgte/nock/issues/163
|
|
Interceptor.prototype.reqheaderMatches = function reqheaderMatches(options, key) {
|
|
// We don't try to match request headers if these weren't even specified in the request.
|
|
if (!options.headers) {
|
|
return true;
|
|
}
|
|
|
|
var reqHeader = this.reqheaders[key];
|
|
var header = options.headers[key];
|
|
if (header && (typeof header !== 'string') && header.toString) {
|
|
header = header.toString();
|
|
}
|
|
|
|
// We skip 'host' header comparison unless it's available in both mock and actual request.
|
|
// This because 'host' may get inserted by Nock itself and then get recorder.
|
|
// NOTE: We use lower-case header field names throughout Nock.
|
|
if (key === 'host' &&
|
|
(_.isUndefined(header) ||
|
|
_.isUndefined(reqHeader))) {
|
|
return true;
|
|
}
|
|
|
|
if (!_.isUndefined(reqHeader) && !_.isUndefined(header)) {
|
|
if (_.isFunction(reqHeader)) {
|
|
return reqHeader(header);
|
|
} else if (common.matchStringOrRegexp(header, reqHeader)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
debug('request header field doesn\'t match:', key, header, reqHeader);
|
|
return false;
|
|
};
|
|
|
|
Interceptor.prototype.match = function match(options, body, hostNameOnly) {
|
|
if (debug.enabled) {
|
|
debug('match %s, body = %s', stringify(options), stringify(body));
|
|
}
|
|
|
|
if (hostNameOnly) {
|
|
return options.hostname === this.scope.urlParts.hostname;
|
|
}
|
|
|
|
var method = (options.method || 'GET').toUpperCase()
|
|
, path = options.path
|
|
, matches
|
|
, matchKey
|
|
, proto = options.proto;
|
|
|
|
if (this.scope.transformPathFunction) {
|
|
path = this.scope.transformPathFunction(path);
|
|
}
|
|
if (typeof (body) !== 'string') {
|
|
body = body.toString();
|
|
}
|
|
if (this.scope.transformRequestBodyFunction) {
|
|
body = this.scope.transformRequestBodyFunction(body, this._requestBody);
|
|
}
|
|
|
|
var checkHeaders = function (header) {
|
|
if (_.isFunction(header.value)) {
|
|
return header.value(options.getHeader(header.name));
|
|
}
|
|
return common.matchStringOrRegexp(options.getHeader(header.name), header.value);
|
|
};
|
|
|
|
if (!this.scope.matchHeaders.every(checkHeaders) ||
|
|
!this.interceptorMatchHeaders.every(checkHeaders)) {
|
|
this.scope.logger('headers don\'t match');
|
|
return false;
|
|
}
|
|
|
|
var reqHeadersMatch =
|
|
!this.reqheaders ||
|
|
Object.keys(this.reqheaders).every(this.reqheaderMatches.bind(this, options));
|
|
|
|
if (!reqHeadersMatch) {
|
|
return false;
|
|
}
|
|
|
|
function reqheaderContains(header) {
|
|
return _.has(options.headers, header);
|
|
}
|
|
|
|
var reqContainsBadHeaders =
|
|
this.badheaders &&
|
|
_.some(this.badheaders, reqheaderContains);
|
|
|
|
if (reqContainsBadHeaders) {
|
|
return false;
|
|
}
|
|
|
|
// If we have a filtered scope then we use it instead reconstructing
|
|
// the scope from the request options (proto, host and port) as these
|
|
// two won't necessarily match and we have to remove the scope that was
|
|
// matched (vs. that was defined).
|
|
if (this.__nock_filteredScope) {
|
|
matchKey = this.__nock_filteredScope;
|
|
} else {
|
|
matchKey = proto + '://' + options.host;
|
|
if (
|
|
options.port && options.host.indexOf(':') < 0 &&
|
|
(options.port !== 80 || options.proto !== 'http') &&
|
|
(options.port !== 443 || options.proto !== 'https')
|
|
) {
|
|
matchKey += ":" + options.port;
|
|
}
|
|
}
|
|
|
|
// Match query strings when using query()
|
|
var matchQueries = true;
|
|
var queryIndex = -1;
|
|
var queryString;
|
|
var queries;
|
|
|
|
if (this.queries) {
|
|
queryIndex = path.indexOf('?');
|
|
queryString = (queryIndex !== -1) ? path.slice(queryIndex + 1) : '';
|
|
queries = qs.parse(queryString);
|
|
|
|
// Only check for query string matches if this.queries is an object
|
|
if (_.isObject(this.queries)) {
|
|
|
|
if (_.isFunction(this.queries)) {
|
|
matchQueries = this.queries(queries);
|
|
} else {
|
|
// Make sure that you have an equal number of keys. We are
|
|
// looping through the passed query params and not the expected values
|
|
// if the user passes fewer query params than expected but all values
|
|
// match this will throw a false positive. Testing that the length of the
|
|
// passed query params is equal to the length of expected keys will prevent
|
|
// us from doing any value checking BEFORE we know if they have all the proper
|
|
// params
|
|
debug('this.queries: %j', this.queries);
|
|
debug('queries: %j', queries);
|
|
if (_.size(this.queries) !== _.size(queries)) {
|
|
matchQueries = false;
|
|
} else {
|
|
var self = this;
|
|
_.forOwn(queries, function matchOneKeyVal(val, key) {
|
|
var expVal = self.queries[key];
|
|
var isMatch = true;
|
|
if (val === undefined || expVal === undefined) {
|
|
isMatch = false;
|
|
} else if (expVal instanceof RegExp) {
|
|
isMatch = common.matchStringOrRegexp(val, expVal);
|
|
} else if (_.isArray(expVal) || _.isObject(expVal)) {
|
|
isMatch = _.isEqual(val, expVal);
|
|
} else {
|
|
isMatch = common.matchStringOrRegexp(val, expVal);
|
|
}
|
|
matchQueries = matchQueries && !!isMatch;
|
|
});
|
|
}
|
|
debug('matchQueries: %j', matchQueries);
|
|
}
|
|
}
|
|
|
|
// Remove the query string from the path
|
|
if (queryIndex !== -1) {
|
|
path = path.substr(0, queryIndex);
|
|
}
|
|
}
|
|
|
|
if (typeof this.uri === 'function') {
|
|
matches = matchQueries &&
|
|
method === this.method &&
|
|
common.matchStringOrRegexp(matchKey, this.basePath) &&
|
|
this.uri.call(this, path);
|
|
} else {
|
|
matches = method === this.method &&
|
|
common.matchStringOrRegexp(matchKey, this.basePath) &&
|
|
common.matchStringOrRegexp(path, this.path) &&
|
|
matchQueries;
|
|
}
|
|
|
|
// special logger for query()
|
|
if (queryIndex !== -1) {
|
|
this.scope.logger('matching ' + matchKey + path + '?' + queryString + ' to ' + this._key +
|
|
' with query(' + stringify(this.queries) + '): ' + matches);
|
|
} else {
|
|
this.scope.logger('matching ' + matchKey + path + ' to ' + this._key + ': ' + matches);
|
|
}
|
|
|
|
if (matches) {
|
|
matches = (matchBody.call(options, this._requestBody, body));
|
|
if (!matches) {
|
|
this.scope.logger('bodies don\'t match: \n', this._requestBody, '\n', body);
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
};
|
|
|
|
Interceptor.prototype.matchIndependentOfBody = function matchIndependentOfBody(options) {
|
|
var isRegex = _.isRegExp(this.path);
|
|
var isRegexBasePath = _.isRegExp(this.scope.basePath);
|
|
|
|
var method = (options.method || 'GET').toUpperCase()
|
|
, path = options.path
|
|
, proto = options.proto;
|
|
|
|
// NOTE: Do not split off the query params as the regex could use them
|
|
if (!isRegex) {
|
|
path = path ? path.split('?')[0] : '';
|
|
}
|
|
|
|
if (this.scope.transformPathFunction) {
|
|
path = this.scope.transformPathFunction(path);
|
|
}
|
|
|
|
var checkHeaders = function (header) {
|
|
return options.getHeader && common.matchStringOrRegexp(options.getHeader(header.name), header.value);
|
|
};
|
|
|
|
if (!this.scope.matchHeaders.every(checkHeaders) ||
|
|
!this.interceptorMatchHeaders.every(checkHeaders)) {
|
|
return false;
|
|
}
|
|
|
|
var comparisonKey = isRegex ? this.__nock_scopeKey : this._key;
|
|
var matchKey = method + ' ' + proto + '://' + options.host + path;
|
|
|
|
if (isRegex && !isRegexBasePath) {
|
|
return !!matchKey.match(comparisonKey) && !!path.match(this.path);
|
|
}
|
|
|
|
if(isRegexBasePath) {
|
|
return !!matchKey.match(this.scope.basePath) && !!path.match(this.path);
|
|
}
|
|
|
|
return comparisonKey === matchKey;
|
|
};
|
|
|
|
Interceptor.prototype.filteringPath = function filteringPath() {
|
|
if (_.isFunction(arguments[0])) {
|
|
this.scope.transformFunction = arguments[0];
|
|
}
|
|
return this;
|
|
};
|
|
|
|
Interceptor.prototype.discard = function discard() {
|
|
if ((this.scope.shouldPersist() || this.counter > 0) && this.filePath) {
|
|
this.body = fs.createReadStream(this.filePath);
|
|
this.body.pause();
|
|
}
|
|
|
|
if (!this.scope.shouldPersist() && this.counter < 1) {
|
|
this.scope.remove(this._key, this);
|
|
}
|
|
};
|
|
|
|
Interceptor.prototype.matchHeader = function matchHeader(name, value) {
|
|
this.interceptorMatchHeaders.push({ name: name, value: value });
|
|
return this;
|
|
};
|
|
|
|
Interceptor.prototype.basicAuth = function basicAuth(options) {
|
|
var username = options['user'];
|
|
var password = options['pass'] || '';
|
|
var name = 'authorization';
|
|
var value = 'Basic ' + Buffer.from(username + ':' + password).toString('base64');
|
|
this.interceptorMatchHeaders.push({ name: name, value: value });
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Set query strings for the interceptor
|
|
* @name query
|
|
* @param Object Object of query string name,values (accepts regexp values)
|
|
* @public
|
|
* @example
|
|
* // Will match 'http://zombo.com/?q=t'
|
|
* nock('http://zombo.com').get('/').query({q: 't'});
|
|
*/
|
|
Interceptor.prototype.query = function query(queries) {
|
|
this.queries = this.queries || {};
|
|
|
|
// Allow all query strings to match this route
|
|
if (queries === true) {
|
|
this.queries = queries;
|
|
return this;
|
|
}
|
|
|
|
if (_.isFunction(queries)) {
|
|
this.queries = queries;
|
|
return this;
|
|
}
|
|
|
|
var stringFormattingFn;
|
|
if (this.scope.scopeOptions.encodedQueryParams) {
|
|
stringFormattingFn = common.percentDecode;
|
|
}
|
|
|
|
for (var key in queries) {
|
|
if (_.isUndefined(this.queries[key])) {
|
|
var formattedPair = common.formatQueryValue(key, queries[key], stringFormattingFn);
|
|
this.queries[formattedPair[0]] = formattedPair[1];
|
|
}
|
|
}
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Set number of times will repeat the interceptor
|
|
* @name times
|
|
* @param Integer Number of times to repeat (should be > 0)
|
|
* @public
|
|
* @example
|
|
* // Will repeat mock 5 times for same king of request
|
|
* nock('http://zombo.com).get('/').times(5).reply(200, 'Ok');
|
|
*/
|
|
Interceptor.prototype.times = function times(newCounter) {
|
|
if (newCounter < 1) {
|
|
return this;
|
|
}
|
|
|
|
this.counter = newCounter;
|
|
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* An sugar syntax for times(1)
|
|
* @name once
|
|
* @see {@link times}
|
|
* @public
|
|
* @example
|
|
* nock('http://zombo.com).get('/').once.reply(200, 'Ok');
|
|
*/
|
|
Interceptor.prototype.once = function once() {
|
|
return this.times(1);
|
|
};
|
|
|
|
/**
|
|
* An sugar syntax for times(2)
|
|
* @name twice
|
|
* @see {@link times}
|
|
* @public
|
|
* @example
|
|
* nock('http://zombo.com).get('/').twice.reply(200, 'Ok');
|
|
*/
|
|
Interceptor.prototype.twice = function twice() {
|
|
return this.times(2);
|
|
};
|
|
|
|
/**
|
|
* An sugar syntax for times(3).
|
|
* @name thrice
|
|
* @see {@link times}
|
|
* @public
|
|
* @example
|
|
* nock('http://zombo.com).get('/').thrice.reply(200, 'Ok');
|
|
*/
|
|
Interceptor.prototype.thrice = function thrice() {
|
|
return this.times(3);
|
|
};
|
|
|
|
/**
|
|
* Delay the response by a certain number of ms.
|
|
*
|
|
* @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
|
|
*/
|
|
Interceptor.prototype.delay = function delay(opts) {
|
|
var headDelay = 0;
|
|
var bodyDelay = 0;
|
|
if (_.isNumber(opts)) {
|
|
headDelay = opts;
|
|
} else if (_.isObject(opts)) {
|
|
headDelay = opts.head || 0;
|
|
bodyDelay = opts.body || 0;
|
|
} else {
|
|
throw new Error("Unexpected input opts" + opts);
|
|
}
|
|
|
|
return this.delayConnection(headDelay)
|
|
.delayBody(bodyDelay);
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
Interceptor.prototype.delayBody = function delayBody(ms) {
|
|
this.delayInMs += ms;
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Delay the connection by a certain number of ms.
|
|
*
|
|
* @param {integer} ms - Number of milliseconds to wait
|
|
* @return {interceptor} - the current interceptor for chaining
|
|
*/
|
|
Interceptor.prototype.delayConnection = function delayConnection(ms) {
|
|
this.delayConnectionInMs += ms;
|
|
return this;
|
|
};
|
|
|
|
Interceptor.prototype.getTotalDelay = function getTotalDelay() {
|
|
return this.delayInMs + this.delayConnectionInMs;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
Interceptor.prototype.socketDelay = function socketDelay(ms) {
|
|
this.socketDelayInMs = ms;
|
|
return this;
|
|
};
|