heroku-client
Version:
A wrapper for the Heroku v3 API
368 lines (317 loc) • 9.69 kB
JavaScript
'use strict';
var https = require('https');
var tunnel = require('tunnel-agent');
var extend = require('util')._extend;
var q = require('q');
var pjson = require('../package.json');
module.exports = Request;
/*
* Create an object capable of making API
* calls. Accepts custom request options and
* a callback function.
*/
function Request(options, callback) {
this.options = options || {};
this.debug = options.debug;
if (this.debug) {
q.longStackSupport = true;
}
this.host = options.host || 'api.heroku.com';
this.partial = options.partial;
this.callback = callback;
this.deferred = q.defer();
this.userAgent = options.userAgent || 'node-heroku-client/' + pjson.version;
this.parseJSON = options.hasOwnProperty('parseJSON') ? options.parseJSON : true;
this.nextRange = 'id ]..; max=1000';
this.logger = options.logger;
this.cache = options.cache;
if (process.env.HEROKU_HTTP_PROXY_HOST) {
this.agent = tunnel.httpsOverHttp({
proxy: {
host: process.env.HEROKU_HTTP_PROXY_HOST,
port: process.env.HEROKU_HTTP_PROXY_PORT || 8080
}
});
} else {
this.agent = new https.Agent({ maxSockets: Number(process.env.HEROKU_CLIENT_MAX_SOCKETS) || 5000 });
}
}
/*
* Instantiate a Request object and makes a
* request, returning the request promise.
*/
Request.request = function request(options, callback) {
var req = new Request(options, function (err, body) {
if (callback) { callback(err, body); }
});
return req.request();
};
/*
* Check for a cached response, then
* perform an API request. Return the
* request object's promise.
*/
Request.prototype.request = function request() {
this.getCache(this.performRequest.bind(this));
return this.deferred.promise;
};
/*
* Perform the actual API request.
*/
Request.prototype.performRequest = function performRequest() {
var req;
this.options.headers = this.options.headers || {};
var headers = extend({
'Accept': 'application/vnd.heroku+json; version=3',
'Content-type': 'application/json',
'User-Agent': this.userAgent,
'Range': this.nextRange
}, this.options.headers);
// remove null|undefined headers
for (var k in headers) {
if (headers.hasOwnProperty(k)) {
if (headers[k] === null || headers[k] === undefined) {
delete headers[k];
}
}
}
var requestOptions = {
agent: this.agent,
host: this.host,
port: 443,
path: this.options.path,
auth: this.options.auth || ':' + this.options.token,
method: this.options.method || 'GET',
rejectUnauthorized: this.options.rejectUnauthorized,
headers: headers
};
if (this.cachedResponse) {
headers['If-None-Match'] = this.cachedResponse.etag;
}
req = https.request(requestOptions, this.handleResponse.bind(this));
this.logRequest(req);
this.writeBody(req);
this.setRequestTimeout(req);
req.on('error', this.handleError.bind(this));
req.end();
return this.deferred.promise;
};
/*
* Handle an API response, returning the API response.
*/
Request.prototype.handleResponse = function handleResponse(res) {
var self = this;
this.logResponse(res);
if (res.statusCode === 304 && this.cachedResponse) {
if (this.cachedResponse.nextRange) {
this.nextRequest(this.cachedResponse.nextRange, this.cachedResponse.body);
} else {
this.updateAggregate(this.cachedResponse.body);
this.deferred.resolve(this.aggregate);
this.callback(null, this.aggregate);
}
return;
}
concat(res, function (data) {
if (self.debug) {
console.error('<-- ' + data);
}
if (res.statusCode.toString().match(/^2\d{2}$/)) {
self.handleSuccess(res, data);
} else {
self.handleFailure(res, data);
}
});
};
function printHeaders (headers) {
var key;
var value;
for (key in headers) {
if (headers.hasOwnProperty(key)) {
value = key.toUpperCase() === 'AUTHORIZATION' ? 'REDACTED' : headers[key];
console.error(' ' + key + '=' + value);
}
}
}
Request.prototype.logRequest = function logRequest(req) {
if (this.debug) {
console.error('--> ' + req.method + ' ' + req.path);
printHeaders(req._headers);
}
};
/*
* Log the API response.
*/
Request.prototype.logResponse = function logResponse(res) {
if (this.logger) {
this.logger.log({
status : res.statusCode,
content_length: res.headers['content-length'],
request_id : res.headers['request-id']
});
}
if (this.debug) {
console.error('<-- ' + res.statusCode + ' ' + res.statusMessage);
printHeaders(res.headers);
}
};
/*
* If the request options include a body,
* write the body to the request and set
* an appropriate 'Content-length' header.
*/
Request.prototype.writeBody = function writeBody(req) {
if (this.options.body) {
var body = this.options.body;
if (this.options.json !== false) { body = JSON.stringify(body); }
if (this.debug) {
console.error('--> ' + body);
}
req.setHeader('Content-length', Buffer.byteLength(body, 'utf8'));
req.write(body);
} else {
req.setHeader('Content-length', 0);
}
};
/*
* If the request options include a timeout,
* set the timeout and provide a callback
* function in case the request exceeds the
* timeout period.
*/
Request.prototype.setRequestTimeout = function setRequestTimeout(req) {
if (!this.options.timeout) { return; }
req.setTimeout(this.options.timeout, function () {
var err = new Error('Request took longer than ' + this.options.timeout + 'ms to complete.');
req.abort();
this.deferred.reject(err);
this.callback(err);
}.bind(this));
};
/*
* Get the request body, and parse it (or not) as appropriate.
* - Parse JSON by default.
* - If parseJSON is `false`, it will not parse.
*/
Request.prototype.parseBody = function parseBody(body) {
if (this.parseJSON) {
return JSON.parse(body || '{}');
} else {
return body;
}
};
/*
* In the event of an error in performing
* the API request, reject the deferred
* object and return an error to the callback.
*/
Request.prototype.handleError = function handleError(err) {
this.deferred.reject(err);
this.callback(err);
};
/*
* In the event of a non-successful API request,
* fail with an appropriate error message and
* status code.
*/
Request.prototype.handleFailure = function handleFailure(res, buffer) {
var callback = this.callback;
var deferred = this.deferred;
var message = 'Expected response to be successful, got ' + res.statusCode;
var err;
err = new Error(message);
err.statusCode = res.statusCode;
err.body = this.parseBody(buffer);
deferred.reject(err);
callback(err);
};
/*
* In the event of a successful API response,
* respond with the response body.
*/
Request.prototype.handleSuccess = function handleSuccess(res, buffer) {
var callback = this.callback;
var deferred = this.deferred;
var body = this.parseBody(buffer);
this.setCache(res, body);
if (!this.partial && res.headers['next-range']) {
this.nextRequest(res.headers['next-range'], body);
} else {
this.updateAggregate(body);
deferred.resolve(this.aggregate);
callback(null, this.aggregate);
}
};
/*
* Since this request isn't the full response (206 or
* 304 with a cached Next-Range), perform the next
* request for more data.
*/
Request.prototype.nextRequest = function nextRequest(nextRange, body) {
this.updateAggregate(body);
this.nextRange = nextRange;
// The initial range header passed in (if there was one), is no longer valid, and should no longer take precedence
delete (this.options.headers.Range);
this.request();
};
/*
* If given an object, sets aggregate to object,
* otherwise concats array onto aggregate.
*/
Request.prototype.updateAggregate = function updateAggregate(aggregate) {
if (aggregate instanceof Array) {
this.aggregate = this.aggregate || [];
this.aggregate = this.aggregate.concat(aggregate);
} else {
this.aggregate = aggregate;
}
};
/*
* If the cache client is alive, get the
* cached response from the cache.
*/
Request.prototype.getCache = function getCache(callback) {
if (!this.cache) { return callback(null); }
var key = this.getCacheKey();
var self = this;
this.cache.store.get(key, function (err, res) {
if (err) { return self.deferred.reject(err); }
self.cachedResponse = res ? self.cache.encryptor.decrypt(res.toString()) : res;
callback();
});
};
/*
* If the cache client is alive, write the
* provided response and body to the cache.
*/
Request.prototype.setCache = function setCache(res, body) {
if ((!this.cache) || !(res.headers.etag)) { return; }
var key = this.getCacheKey();
var value = {
body : body,
etag : res.headers.etag,
nextRange: res.headers['next-range']
};
if (this.debug) {
console.error('<-- writing to cache');
}
value = this.cache.encryptor.encrypt(value);
this.cache.store.set(key, value);
};
/*
* Returns a cache key comprising the request path,
* the 'Next Range' header, and the user's API token.
*/
Request.prototype.getCacheKey = function getCacheKey() {
var path = JSON.stringify([this.options.path, this.nextRange, this.options.token]);
return this.cache.encryptor.hmac(path);
};
function concat (stream, callback) {
var strings = [];
stream.on('data', function (data) {
strings.push(data);
});
stream.on('end', function () {
callback(strings.join(''));
});
}