eight-track
Version:
Record and playback HTTP requests
257 lines (225 loc) • 8.5 kB
JavaScript
var assert = require('assert');
var crypto = require('crypto');
var url = require('url');
var _ = require('underscore');
var async = require('async');
var Store = require('fs-memory-store');
var request = require('request');
var Message = require('./message');
function EightTrack(options) {
// Assert we received the expected options
assert(options.url, '`eight-track` expected `options.url` but did not receive it');
assert(options.fixtureDir, '`eight-track` expected `options.fixtureDir` but did not receive it');
// Pre-emptively parse options
var remoteUrl = options.url;
if (typeof remoteUrl === 'string') {
remoteUrl = url.parse(remoteUrl);
}
// Save remoteUrl and fixtureDir for later
this.remoteUrl = remoteUrl;
this.normalizeFn = options.normalizeFn || _.identity;
this.store = new Store(options.fixtureDir);
}
EightTrack.prototype = {
getConnectionKey: function (conn) {
// Generate an object representing the request
var info = conn.getRequestInfo();
// Normalize the info
info = this.normalizeFn(info) || info;
// Stringify the info and hash it
if (info.body && Buffer.isBuffer(info.body)) {
info.body = info.body.toString('base64');
}
var json = JSON.stringify(info);
var md5 = crypto.createHash('md5');
md5.update(json);
var hash = md5.digest('hex');
// Compound method, url, and hash to generate the key
// DEV: We truncate URL at 32 characters to prevent ENAMETOOLONG
// https://github.com/uber/eight-track/issues/7
var url = encodeURIComponent(info.url).substr(0, 32);
return info.method + '_' + url + '_' + hash;
},
_serializeBody: function (obj) {
// Serialize the buffer for disk
var _buff = obj.body;
var bodyEncoding = 'utf8';
var body = _buff.toString(bodyEncoding);
// If the buffer is not utf8-friendly, serialize it to base64
var testBuffer = new Buffer(body, bodyEncoding);
if (testBuffer.length !== _buff.length) {
bodyEncoding = 'base64';
body = _buff.toString(bodyEncoding);
}
// Save the new body
var retObj = _.omit(obj, 'body');
retObj.bodyEncoding = bodyEncoding;
retObj.body = body;
// Return our object ready for serialization
return retObj;
},
getConnection: function (key, cb) {
this.store.get(key, function handleGet (err, info) {
// If there was an error, callback with it
if (err) {
return cb(err);
// Otherwise, if there was no info, callback with it
} else if (!info) {
return cb(err, info);
}
// Otherwise, de-serialize the buffer
var _body = info.response.body;
info.response.body = _body.length ? new Buffer(_body, info.response.bodyEncoding || 'utf8') : '';
cb(null, info);
});
},
saveConnection: function (key, _info, cb) {
var info = _.clone(_info);
info.request = this._serializeBody(info.request);
info.response = this._serializeBody(info.response);
this.store.set(key, info, cb);
},
createRemoteRequest: function (localReqMsg) {
// Prepate the URL for headers logic
// TODO: It feels like URL extension deserves to be its own node module
// http://nodejs.org/api/url.html#url_url
/*
headers: local (+ remote host)
protocol: remote,
hostname: remote,
port: remote,
pathname: remote + local, (e.g. /abc + /def -> /abc/def)
query: local
*/
var localReq = localReqMsg.connection;
var localUrl = url.parse(localReq.url);
var _url = _.pick(this.remoteUrl, 'protocol', 'hostname', 'port');
// If the remotePathname is a `/`, convert it to a ''. Node decides that all URLs deserve a `pathname` even when not provided
var remotePathname = this.remoteUrl.pathname || '';
if (remotePathname === '/') {
remotePathname = '';
}
// DEV: We use string concatenation because we cannot predict how all servers are designed
_url.pathname = remotePathname + (localUrl.pathname || '');
_url.search = localUrl.query;
// Set up headers
var headers = localReq.headers;
// If there is a host, use our new host for the request
if (headers.host) {
headers = _.clone(headers);
delete headers.host;
// Logic taken from https://github.com/mikeal/request/blob/v2.30.1/request.js#L193-L202
headers.host = _url.hostname;
if (_url.port) {
if ( !(_url.port === 80 && _url.protocol === 'http:') &&
!(_url.port === 443 && _url.protocol === 'https:') ) {
headers.host += ':' + _url.port;
}
}
}
// Forward the original request to the new server
var remoteReq = request({
// DEV: Missing `httpVersion`
headers: headers,
// DEV: request does not support `trailers`
trailers: localReq.trailers,
method: localReq.method,
url: url.format(_url),
body: localReqMsg.body,
// DEV: This is probably an indication that we should no longer use `request`. See #19.
followRedirect: false
});
return remoteReq;
},
forwardRequest: function (localReq, callback) {
// Create a connection to pass around between methods
// DEV: This cannot be placed inside the waterfall since in 0.8, we miss data + end events
var localReqMsg = new Message(localReq);
var requestKey, remoteResMsg, connInfo;
function sendConnInfo(connInfo) {
return callback(null, connInfo.response, connInfo.response.body);
}
// Create marker for request loading before we get to `loadIncomingBody` listener
var localReqLoaded = false;
localReqMsg.on('loaded', function updateLoadedState () {
localReqLoaded = true;
});
var that = this;
async.waterfall([
function loadIncomingBody (cb) {
if (localReqLoaded) {
return process.nextTick(cb);
}
localReqMsg.on('loaded', cb);
},
function findSavedConnection (cb) {
requestKey = that.getConnectionKey(localReqMsg);
that.getConnection(requestKey, cb);
},
function createRemoteReq (connInfo, cb) {
// If we successfully found the info, reply with it
if (connInfo) {
return sendConnInfo(connInfo);
}
// Forward the original request to the new server
var remoteReq = that.createRemoteRequest(localReqMsg);
// When we receive a response, load the response body
remoteReq.on('error', cb);
remoteReq.on('response', function handleRes (remoteRes) {
remoteResMsg = new Message(remoteRes);
remoteResMsg.on('loaded', cb);
});
},
function saveIncomingRemote (cb) {
// Save the incoming request and remote response info
connInfo = {
request: localReqMsg.getRequestInfo(),
response: remoteResMsg.getResponseInfo()
};
that.saveConnection(requestKey, connInfo, cb);
}
], function handleResponseInfo (err) {
if (err) {
return callback(err);
} else {
return sendConnInfo(connInfo);
}
});
},
handleConnection: function (localReq, localRes) {
// DEV: remoteRes is not request's response but an internal response format
this.forwardRequest(localReq, function handleForwardedResponse (err, remoteRes, remoteBody) {
// If there was an error, emit it
if (err) {
localReq.emit('error', err);
// Otherwise, send the response
} else {
localRes.writeHead(remoteRes.statusCode, remoteRes.headers);
localRes.write(remoteBody);
localRes.end();
}
});
}
};
function middlewareCreator(options) {
// Create a new eight track for our middleware
var eightTrack = new EightTrack(options);
// Define a middleware to handle requests `(req, res)`
function eightTrackMiddleware(localReq, localRes) {
eightTrack.handleConnection(localReq, localRes);
}
// Add on prototype methods (e.g. `forwardRequest`)
var keys = Object.getOwnPropertyNames(EightTrack.prototype);
keys.forEach(function bindEightTrackMethod (key) {
eightTrackMiddleware[key] = function executeEightTrackMethod () {
eightTrack[key].apply(eightTrack, arguments);
};
});
// Return the middleware
return eightTrackMiddleware;
}
// Expose class on top of middlewareCreator
middlewareCreator.EightTrack = EightTrack;
middlewareCreator.Message = Message;
// Expose our middleware constructor
module.exports = middlewareCreator;