heroku-client
Advanced tools
Comparing version 0.2.0 to 1.0.0
@@ -1,111 +0,67 @@ | ||
var inflection = require('inflection'), | ||
client = require('./request'), | ||
resources = require('./resources').resources, | ||
_ = require('underscore'); | ||
var Request = require('./request'); | ||
exports.request = client.request; | ||
exports.Heroku = Heroku; | ||
module.exports = Heroku; | ||
function Heroku(options) { | ||
this.request = function(_options, callback) { | ||
if (typeof _options === 'function') { | ||
callback = _options; | ||
_options = options; | ||
} else { | ||
_options = _.extend(_.clone(options), _options); | ||
} | ||
return client.request(_options, function(err, body) { | ||
if (callback) callback(err, body); | ||
}); | ||
}; | ||
function Heroku (options) { | ||
this.options = options; | ||
} | ||
_.each(resources, function (resource) { | ||
buildResource(resource); | ||
}); | ||
Heroku.configure = function configure (config) { | ||
if (config.cache && !process.env.HEROKU_CLIENT_ENCRYPTION_SECRET) { | ||
console.error('Must supply HEROKU_CLIENT_ENCRYPTION_SECRET in order to cache'); | ||
process.exit(1); | ||
} | ||
function buildResource (resource) { | ||
_.each(resource.actions, function (action, actionName) { | ||
buildAction(action, actionName); | ||
}); | ||
} | ||
if (config.cache) { | ||
Request.connectCacheClient(); | ||
} | ||
function buildAction (action, actionName) { | ||
var constructor = getResource(action.path); | ||
constructor.prototype[getName(actionName)] = function (body, callback) { | ||
var requestPath = action.path, | ||
callback; | ||
this.params.forEach(function (param) { | ||
requestPath = requestPath.replace(/{[a-z_]+}/, param); | ||
}); | ||
var options = { | ||
method: action.method, | ||
path: requestPath, | ||
expectedStatus: action.statuses | ||
}; | ||
if (typeof arguments[0] === 'function') { | ||
callback = body; | ||
} else if (typeof arguments[0] === 'object') { | ||
options = _.extend(options, { body: body }); | ||
} | ||
return this.client.request(options, callback); | ||
}; | ||
return this; | ||
} | ||
function getResource(path) { | ||
var proxy = Heroku, | ||
segments; | ||
Heroku.request = Request.request; | ||
path = path.split(/\//); | ||
segments = path.slice(1, path.length); | ||
Heroku.prototype.request = function request (options, callback) { | ||
var key; | ||
segments.forEach(function (segment) { | ||
var constructor; | ||
if (proxy.prototype && proxy.prototype[segment]) { | ||
return proxy = proxy.prototype[segment]._constructor; | ||
if (typeof options === 'function') { | ||
callback = options; | ||
options = this.options; | ||
} else { | ||
for (key in this.options) { | ||
if (Object.keys(options).indexOf(key) == -1) options[key] = this.options[key]; | ||
} | ||
} | ||
if (!segment.match(/{.*}/)) { | ||
constructor = function (client, params) { | ||
this.client = client; | ||
this.params = params; | ||
}; | ||
return Request.request(options, function requestCallback (err, body) { | ||
if (callback) callback(err, body); | ||
}); | ||
}; | ||
proxy.prototype[segment] = function (param) { | ||
var client, params, resource; | ||
Heroku.prototype.get = function get (path, callback) { | ||
return this.request({ method: 'GET', path: path }, callback); | ||
}; | ||
if (this instanceof Heroku) { | ||
client = this; | ||
} else { | ||
client = this.client; | ||
} | ||
Heroku.prototype.post = function post (path, body, callback) { | ||
if (typeof body === 'function') { | ||
callback = body; | ||
body = {}; | ||
} | ||
params = this.params || []; | ||
if (param) params = params.concat(param); | ||
return this.request({ method: 'POST', path: path, body: body }, callback); | ||
}; | ||
return new constructor(client, params); | ||
}; | ||
Heroku.prototype.patch = function patch (path, body, callback) { | ||
if (typeof body === 'function') { | ||
callback = body; | ||
body = {}; | ||
} | ||
proxy.prototype[segment]._constructor = constructor; | ||
return this.request({ method: 'PATCH', path: path, body: body }, callback); | ||
}; | ||
return proxy = constructor; | ||
} | ||
}); | ||
Heroku.prototype.delete = function _delete (path, callback) { | ||
return this.request({ method: 'DELETE', path: path }, callback); | ||
}; | ||
return proxy; | ||
} | ||
function getName(name) { | ||
name = name.toLowerCase(); | ||
name = inflection.dasherize(name).replace(/-/g, '_'); | ||
name = inflection.camelize(name, true); | ||
return name; | ||
} | ||
require('./resourceBuilder').build(); |
@@ -1,108 +0,189 @@ | ||
var https = require('https'), | ||
memjs = require('memjs'), | ||
q = require('q'), | ||
_ = require('underscore'), | ||
var https = require('https'), | ||
agent = new https.Agent({ maxSockets: Number(process.env.HEROKU_CLIENT_MAX_SOCKETS) || 5000 }), | ||
concat = require('concat-stream'), | ||
encryptor = require('./encryptor'), | ||
memjs = require('memjs'), | ||
q = require('q'), | ||
cache; | ||
exports.request = function request(options, callback) { | ||
options || (options = {}); | ||
var deferred = q.defer(), | ||
path = options.path; | ||
module.exports = Request; | ||
getCache(path, options.cacheKeyPostfix, function(cachedResponse) { | ||
var headers = _.extend({ | ||
'Accept': 'application/vnd.heroku+json; version=3', | ||
'Content-type': 'application/json' | ||
}, options.headers || {}); | ||
if (cachedResponse) { | ||
headers['If-None-Match'] = cachedResponse.etag; | ||
} | ||
/* | ||
* Create an object capable of making API | ||
* calls. Accepts custom request options and | ||
* a callback function. | ||
*/ | ||
function Request (options, callback) { | ||
this.options = options || {}; | ||
this.callback = callback; | ||
this.deferred = q.defer(); | ||
this.nextRange = 'id ]..; max=1000'; | ||
} | ||
var requestOptions = { | ||
hostname: 'api.heroku.com', | ||
port: 443, | ||
path: path, | ||
auth: ':' + options.token, | ||
method: options.method || 'GET', | ||
headers: headers | ||
}; | ||
var req = https.request(requestOptions, function(res) { | ||
if (res.statusCode === 304 && cachedResponse) { | ||
deferred.resolve(cachedResponse.body); | ||
callback(null, cachedResponse.body); | ||
} else { | ||
var buffer = ''; | ||
/* | ||
* 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); | ||
}); | ||
res.on('data', function(data) { | ||
buffer += data; | ||
}); | ||
return req.request(); | ||
}; | ||
res.on('end', function() { | ||
if (expectedStatus(res, options)) { | ||
handleSuccess(res, buffer, options, deferred, callback); | ||
} else { | ||
handleFailure(res, buffer, options, deferred, callback); | ||
} | ||
}); | ||
} | ||
}); | ||
if (options.body) { | ||
var body = JSON.stringify(options.body); | ||
/* | ||
* 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; | ||
}; | ||
req.setHeader('Content-length', body.length); | ||
req.write(body); | ||
} | ||
req.on('error', function(err) { | ||
deferred.reject(err); | ||
callback(err); | ||
}); | ||
/* | ||
* Perform the actual API request. | ||
*/ | ||
Request.prototype.performRequest = function performRequest (cachedResponse) { | ||
var headers, | ||
key, | ||
requestOptions, | ||
req; | ||
if (options.timeout && options.timeout > 0) { | ||
req.setTimeout(options.timeout, function() { | ||
req.abort(); | ||
this.cachedResponse = cachedResponse; | ||
var err = new Error('Request took longer than ' + options.timeout + 'ms to complete.'); | ||
deferred.reject(err); | ||
callback(err); | ||
}); | ||
} | ||
headers = { | ||
'Accept': 'application/vnd.heroku+json; version=3', | ||
'Content-type': 'application/json', | ||
'Range': this.nextRange | ||
}; | ||
req.end(); | ||
}); | ||
this.options.headers || (this.options.headers = {}); | ||
for (key in this.options.headers) { | ||
headers[key] = this.options.headers[key]; | ||
} | ||
return deferred.promise; | ||
} | ||
if (this.cachedResponse) { | ||
headers['If-None-Match'] = this.cachedResponse.etag; | ||
} | ||
function expectedStatus(res, options) { | ||
if (options.expectedStatus) { | ||
if (Array.isArray(options.expectedStatus)) { | ||
return options.expectedStatus.indexOf(res.statusCode) > -1 | ||
requestOptions = { | ||
agent: agent, | ||
host: 'api.heroku.com', | ||
port: 443, | ||
path: this.options.path, | ||
auth: ':' + this.options.token, | ||
method: this.options.method || 'GET', | ||
headers: headers | ||
}; | ||
req = https.request(requestOptions, this.handleResponse.bind(this)); | ||
this.writeBody(req); | ||
this.setRequestTimeout(req); | ||
req.on('error', this.handleError.bind(this)); | ||
req.end(); | ||
}; | ||
/* | ||
* Handle an API response, returning the | ||
* cached body if it's still valid, or the | ||
* new API response. | ||
*/ | ||
Request.prototype.handleResponse = function handleResponse (res) { | ||
var _this = this, | ||
resReader = concat(directResponse); | ||
if (res.statusCode === 304 && this.cachedResponse) { | ||
if (this.cachedResponse.nextRange) { | ||
this.nextRequest(this.cachedResponse.nextRange, this.cachedResponse.body); | ||
} else { | ||
return res.statusCode === options.expectedStatus; | ||
this.updateAggregate(this.cachedResponse.body); | ||
this.deferred.resolve(this.aggregate); | ||
this.callback(null, this.aggregate); | ||
} | ||
} else { | ||
return res.statusCode.toString().match(/^2\d{2}$/); | ||
res.pipe(resReader); | ||
} | ||
function directResponse (data) { | ||
if (res.statusCode.toString().match(/^2\d{2}$/)) { | ||
_this.handleSuccess(res, data); | ||
} else { | ||
_this.handleFailure(res, data); | ||
} | ||
} | ||
}; | ||
/* | ||
* 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) return; | ||
var body = JSON.stringify(this.options.body); | ||
req.setHeader('Content-length', body.length); | ||
req.write(body); | ||
} | ||
function handleFailure(res, buffer, options, deferred, callback) { | ||
var statusString = options.expectedStatus, | ||
message; | ||
if (options.expectedStatus) { | ||
if (Array.isArray(options.expectedStatus)) { | ||
statusString = JSON.stringify(options.expectedStatus); | ||
} | ||
/* | ||
* 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) { | ||
var _this = this; | ||
message = 'Expected response ' + statusString + ', got ' + res.statusCode | ||
} else { | ||
message = 'Expected response to be successful, got ' + res.statusCode | ||
} | ||
if (!this.options.timeout) return; | ||
var err = new Error(message); | ||
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); | ||
}); | ||
} | ||
/* | ||
* 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 options = this.options, | ||
callback = this.callback, | ||
deferred = this.deferred, | ||
message = 'Expected response to be successful, got ' + res.statusCode, | ||
err; | ||
err = new Error(message); | ||
err.statusCode = res.statusCode; | ||
@@ -115,39 +196,102 @@ err.body = JSON.parse(buffer || "{}"); | ||
function handleSuccess(res, buffer, options, deferred, callback) { | ||
var body = JSON.parse(buffer || '{}'); | ||
setCache(options.path, options.cacheKeyPostfix, res, body); | ||
/* | ||
* In the event of a successful API response, | ||
* write the response to the cache and resolve | ||
* with the response body. | ||
*/ | ||
Request.prototype.handleSuccess = function handleSuccess (res, buffer) { | ||
var options = this.options, | ||
callback = this.callback, | ||
deferred = this.deferred, | ||
body = JSON.parse(buffer || '{}'); | ||
deferred.resolve(body); | ||
callback(null, body); | ||
this.setCache(res, body); | ||
if (res.headers['next-range']) { | ||
this.nextRequest(res.headers['next-range'], body); | ||
} else { | ||
this.updateAggregate(body); | ||
deferred.resolve(this.aggregate); | ||
callback(null, this.aggregate); | ||
} | ||
} | ||
function getCache(path, postfix, callback) { | ||
/* | ||
* 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; | ||
this.request(); | ||
} | ||
/* | ||
* If the cache client is alive, get the | ||
* cached response from the cache. | ||
*/ | ||
Request.prototype.getCache = function getCache (callback) { | ||
if (!cache) return callback(null); | ||
var key = path + '-' + postfix; | ||
var key = this.getCacheKey(); | ||
cache.get(key, function(err, res) { | ||
cache.get(key, function (err, res) { | ||
res = res ? encryptor.decrypt(res.toString()) : res; | ||
callback(JSON.parse(res)); | ||
}); | ||
} | ||
}; | ||
function setCache(path, cacheKeyPostfix, res, body) { | ||
/* | ||
* If the cache client is alive, write the | ||
* provided response and body to the cache. | ||
*/ | ||
Request.prototype.setCache = function setCache (res, body) { | ||
if ((!cache) || !(res.headers.etag)) return; | ||
var key = path + '-' + cacheKeyPostfix; | ||
var key = this.getCacheKey(); | ||
var value = JSON.stringify({ | ||
body: body, | ||
etag: res.headers.etag | ||
etag: res.headers.etag, | ||
nextRange: res.headers['next-range'] | ||
}); | ||
value = encryptor.encrypt(value); | ||
cache.set(key, value); | ||
} | ||
exports.connectCacheClient = function connectCacheClient() { | ||
cache = memjs.Client.create(); | ||
/* | ||
* Returns a cache key comprising the request path, | ||
* the 'Next Range' header, and the user's API token. | ||
*/ | ||
Request.prototype.getCacheKey = function getCacheKey () { | ||
return encryptor.encrypt(this.options.path + this.nextRange + this.options.token); | ||
}; | ||
if (process.env.NODE_ENV === 'production') { | ||
exports.connectCacheClient(); | ||
/* | ||
* 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; | ||
} | ||
} | ||
/* | ||
* Connect a cache client. | ||
*/ | ||
Request.connectCacheClient = function connectCacheClient() { | ||
cache = memjs.Client.create(); | ||
}; |
{ | ||
"name": "heroku-client", | ||
"version": "0.2.0", | ||
"version": "1.0.0", | ||
"description": "A wrapper for the Heroku v3 API", | ||
@@ -11,3 +11,3 @@ "main": "./lib/heroku.js", | ||
"type": "git", | ||
"url": "https://github.com/jclem/node-heroku" | ||
"url": "https://github.com/jclem/node-heroku-client" | ||
}, | ||
@@ -20,3 +20,3 @@ "keywords": [ | ||
"bugs": { | ||
"url": "https://github.com/jclem/node-heroku/issues" | ||
"url": "https://github.com/jclem/node-heroku-client/issues" | ||
}, | ||
@@ -29,5 +29,6 @@ "devDependencies": { | ||
"memjs": "~0.6.0", | ||
"underscore": "~1.5.1", | ||
"inflection": "~1.2.6" | ||
"inflection": "~1.2.6", | ||
"concat-stream": "~1.1.0", | ||
"path-proxy": "~1.0" | ||
} | ||
} |
140
README.md
@@ -5,30 +5,105 @@ # heroku-client [![Build Status](https://travis-ci.org/jclem/node-heroku-client.png?branch=master)](https://travis-ci.org/jclem/node-heroku-client) | ||
## Install | ||
```sh | ||
$ npm install heroku-client --save | ||
``` | ||
## Documentation | ||
Docs are auto-generated and live in the [docs directory](https://github.com/heroku/node-heroku-client/tree/development/docs). | ||
## Usage | ||
`heroku-client` works by providing functions that return proxy objects for | ||
interacting with different resources through the Heroku API. | ||
To begin, require the Heroku module and create a client, passing in an API | ||
token: | ||
```javascript | ||
// Create a new client and give it an API token | ||
var Heroku = require('heroku-client').Heroku; | ||
heroku = new Heroku({ token: user.apiToken }); | ||
var Heroku = require('heroku-client'), | ||
heroku = new Heroku({ token: process.env.HEROKU_API_TOKEN }); | ||
``` | ||
The simplest example is listing a user's apps. First, we call `heroku.apps()`, | ||
which returns a proxy object to the /apps endpoint, then we call `list()` to | ||
actually perform the API call: | ||
```javascript | ||
heroku.apps().list(function (err, apps) { | ||
console.log(apps); | ||
// `apps` is a parsed JSON response from the API | ||
}); | ||
``` | ||
heroku.apps('my-app').info(function (err, app) { | ||
console.log(app); | ||
The advantage of using proxy objects is that they are reusable. Let's get the | ||
info for the user's app "my-app", get the dynos for the app, and | ||
remove a collaborator: | ||
```javascript | ||
var app = heroku.apps('my-app'); | ||
app.info(function (err, app) { | ||
// Details about the `app` | ||
}); | ||
heroku.apps().create({ name: 'my-new-app' }, function (err, app) { | ||
console.log(app); | ||
app.dynos().list(function (err, dynos) { | ||
// List of the app's `dynos` | ||
}); | ||
var newPlan = { plan: { name: 'papertrail:fixa' } }; | ||
heroku.apps('my-app').addons('papertrail').update(newPlan, function (err, addon) { | ||
console.log(addon); | ||
app.collaborators('user@example.com').delete(function (err, collaborator) { | ||
// The `collaborator` has been removed unless `err` | ||
}); | ||
``` | ||
Requests that require a body are easy, as well. Let's add a collaborator to | ||
the user's app "another-app": | ||
```javascript | ||
var app = heroku.apps('another-app'), | ||
user = { email: 'new-user@example.com' }; | ||
app.collaborators().create({ user: user }, function (err, collaborator) { | ||
// `collaborator` is the newly added collaborator unless `err` | ||
}); | ||
``` | ||
### Generic Requests | ||
heroku-client has `get`, `post`, `patch`, and `delete` functions which can make requests with the specified HTTP method to any endpoint: | ||
```javascript | ||
heroku.get('/apps', function (err, apps) { | ||
}); | ||
// Request body is optional on both `post` and `patch` | ||
heroku.post('/apps', function (err, app) { | ||
}); | ||
heroku.post('/apps', { name: 'my-new-app' }, function (err, app) { | ||
}); | ||
heroku.patch('/apps/my-app', { name: 'my-renamed-app' }, function (err, app) { | ||
}); | ||
heroku.delete('/apps/my-old-app', function (err, app) { | ||
}); | ||
``` | ||
There is also an even more generic `request` function that can accept many more options: | ||
```javascript | ||
heroku.request({ | ||
method: 'GET', | ||
path: '/apps', | ||
headers: { | ||
'Foo': 'Bar' | ||
} | ||
}, function (err, responseBody) { | ||
}); | ||
``` | ||
### Promises | ||
heroku-client works with Node-style callbacks, but also implements promises with the [q][q] library. | ||
heroku-client works with Node-style callbacks, but also implements promises with the [Q][q] library. | ||
@@ -54,21 +129,46 @@ ```javascript | ||
When `NODE_ENV` is set to "production", heroku-client will create a memcached client using [memjs][memjs]. See the memjs repo for configuration instructions. | ||
heroku-client performs caching by creating a memcached client using [memjs][memjs]. See the memjs repo for environment-specific configuration instructions and details. | ||
For local development with caching, it's enough to start a memcached server and set `MEMCACHIER_SERVERS` to `0.0.0.0:11211` in your `.env` file. | ||
heroku-client will cache any response from the Heroku API that comes with an `ETag` header, and each response is cached individually (i.e. even though the client might make multiple calls for a user's apps and then aggregate them into a single JSON array, each required API call is individually cached). For each API request it performs, heroku-client sends an `If-None-Match` header if there is a cached response for the API request. If API returns a 304 response code, heroku-client returns the cached response. Otherwise, it writes the new API response to the cache and returns that. | ||
You will also need to pass an option called `cacheKeyPostfix` when creating your heroku-client client: | ||
To tell heroku-client to perform caching, call the `configure` function: | ||
```javascript | ||
var heroku = new Heroku({ token: user.apiToken, cacheKeyPostfix: user.id }); | ||
var Heroku = require('heroku').configure({ cache: true }); | ||
``` | ||
This ensures that API responses are cached and properly scoped to the user that heroku-client is making requests on behalf of. | ||
This requires a `MEMCACHIER_SERVERS` environment variable, as well as a `HEROKU_CLIENT_ENCRYPTION_SECRET` environment variable that heroku-client uses to build cache keys and encrypt cache contents. | ||
`HEROKU_CLIENT_ENCRYPTION_SECRET` should be a long, random string of characters. heroku-client includes [`bin/secret`][bin_secret] as one way of generating values for this variable. **Do not publish this secret or commit it to source control. If it's compromised, flush your memcache and generate a new encryption secret.** | ||
`MEMCACHIER_SERVERS` can be a single `hostname:port` memache address, or a comma-separated list of memcache addresses, e.g. `example.com:11211,example.net:11211`. Note that while the environment variable that memjs looks for is [named for the MemCachier service it was originally built for][memcachier], it will work with any memcache server that speaks the binary protocol. | ||
## Contributing | ||
### Updating resources | ||
To fetch the latest schema, generate documentation, and run the tests: | ||
```sh | ||
$ bin/update | ||
``` | ||
Inspect your changes, and [bump the version number accordingly](http://semver.org/) when cutting a release. | ||
### Generating documentation | ||
Documentation for heroku-client is auto-generated from [the resources manifest](https://github.com/heroku/node-heroku-client/blob/development/lib/resources.js). | ||
Docs are generated like so: | ||
```bash | ||
$ bin/docs | ||
``` | ||
Generating docs also runs a cursory test, ensuring that every documented function *is* a function that can be called. | ||
### Running tests | ||
node-heroku uses [jasmine-node][jasmine-node] for tests: | ||
heroku-client uses [jasmine-node][jasmine-node] for tests: | ||
```javascript | ||
```bash | ||
$ npm test | ||
@@ -80,2 +180,4 @@ ``` | ||
[memjs]: https://github.com/alevy/memjs | ||
[bin_secret]: https://github.com/heroku/node-heroku-client/blob/development/bin/secret | ||
[memcachier]: https://www.memcachier.com | ||
[jasmine-node]: https://github.com/mhevery/jasmine-node |
module.exports = MockCache; | ||
var encryptor = require('../../lib/encryptor'); | ||
function MockCache() { | ||
@@ -7,4 +9,7 @@ } | ||
MockCache.prototype.get = function(key, callback) { | ||
var body = { cachedFoo: "bar" }; | ||
callback(null, JSON.stringify({ etag: '123', body: body })); | ||
var body = { cachedFoo: "bar" }, | ||
value = JSON.stringify({ etag: '123', body: body }); | ||
value = encryptor.encrypt(value); | ||
callback(null, value); | ||
}; | ||
@@ -11,0 +16,0 @@ |
@@ -1,2 +0,2 @@ | ||
var EventEmitter = require("events").EventEmitter; | ||
var Stream = require('stream').Stream; | ||
@@ -14,4 +14,4 @@ module.exports = MockResponse; | ||
for (var key in EventEmitter.prototype) { | ||
MockResponse.prototype[key] = EventEmitter.prototype[key]; | ||
for (var key in Stream.prototype) { | ||
MockResponse.prototype[key] = Stream.prototype[key]; | ||
} |
@@ -1,8 +0,8 @@ | ||
var client = require('../../lib/request'), | ||
herokuModule = require('../../lib/heroku'), | ||
heroku = new herokuModule.Heroku({ key: '12345' }); | ||
var Heroku = require('../../lib/heroku'), | ||
Request = require('../../lib/request'), | ||
heroku = new Heroku({ token: '12345' }); | ||
describe('Heroku', function() { | ||
beforeEach(function() { | ||
spyOn(client, 'request').andCallFake(function(options, callback) { | ||
spyOn(Request, 'request').andCallFake(function(options, callback) { | ||
callback(); | ||
@@ -12,13 +12,13 @@ }); | ||
it('passes its method into the request', function(done) { | ||
heroku.apps('my-app').create({}, function() { | ||
expect(client.request.mostRecentCall.args[0].method).toEqual('POST'); | ||
done(); | ||
it('passes its method into the request', function() { | ||
heroku.apps().create({}, function() { | ||
expect(Request.request.mostRecentCall.args[0].method).toEqual('POST'); | ||
}); | ||
}); | ||
it('passes its expected status into the request', function(done) { | ||
heroku.apps('my-app').dynos().list(function() { | ||
expect(client.request.mostRecentCall.args[0].expectedStatus).toEqual([200, 206]); | ||
done(); | ||
describe('requests with the wrong number of parameters', function() { | ||
it('throws an error', function() { | ||
expect(function () { | ||
heroku.apps('my-app').list(); | ||
}).toThrow(new Error('Invalid number of params in path (expected 0, got 1).')); | ||
}); | ||
@@ -28,20 +28,17 @@ }); | ||
describe('requests with no body', function() { | ||
it('can perform a request with no parameters', function(done) { | ||
it('can perform a request with no parameters', function() { | ||
heroku.apps().list(function() { | ||
expect(client.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
}); | ||
}); | ||
it('can perform a request with one parameter', function(done) { | ||
it('can perform a request with one parameter', function() { | ||
heroku.apps('my-app').info(function() { | ||
expect(client.request.mostRecentCall.args[0].path).toEqual('/apps/my-app'); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps/my-app'); | ||
}); | ||
}); | ||
it('can perform a request with multiple parameters', function(done) { | ||
it('can perform a request with multiple parameters', function() { | ||
heroku.apps('my-app').collaborators('jonathan@heroku.com').info(function() { | ||
expect(client.request.mostRecentCall.args[0].path).toEqual('/apps/my-app/collaborators/jonathan@heroku.com'); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps/my-app/collaborators/jonathan@heroku.com'); | ||
}); | ||
@@ -52,13 +49,11 @@ }); | ||
describe('requests with a body and no parameters', function() { | ||
it('requests the correct path', function(done) { | ||
it('requests the correct path', function() { | ||
heroku.apps().create({ name: 'my-app' }, function() { | ||
expect(client.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
}); | ||
}); | ||
it('sends the request body', function(done) { | ||
it('sends the request body', function() { | ||
heroku.apps().create({ name: 'my-new-app' }, function() { | ||
expect(client.request.mostRecentCall.args[0].body).toEqual({ name: 'my-new-app' }); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].body).toEqual({ name: 'my-new-app' }); | ||
}); | ||
@@ -69,13 +64,11 @@ }); | ||
describe('requests with a body and one parameter', function() { | ||
it('requests the correct path', function(done) { | ||
it('requests the correct path', function() { | ||
heroku.apps('my-app').addons().create({ name: 'papertrail:choklad' }, function() { | ||
expect(client.request.mostRecentCall.args[0].path).toEqual('/apps/my-app/addons'); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps/my-app/addons'); | ||
}); | ||
}); | ||
it('sends the request body', function(done) { | ||
it('sends the request body', function() { | ||
heroku.apps('my-app').addons().create({ name: 'papertrail:choklad' }, function() { | ||
expect(client.request.mostRecentCall.args[0].body).toEqual({ name: 'papertrail:choklad' }); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].body).toEqual({ name: 'papertrail:choklad' }); | ||
}); | ||
@@ -86,16 +79,86 @@ }); | ||
describe('requests with a body and multiple parameters', function() { | ||
it('requests the correct path', function(done) { | ||
it('requests the correct path', function() { | ||
heroku.apps('my-app').addons('papertrail:choklad').update({ name: 'papertrail:fixa' }, function() { | ||
expect(client.request.mostRecentCall.args[0].path).toEqual('/apps/my-app/addons/papertrail:choklad'); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps/my-app/addons/papertrail:choklad'); | ||
}); | ||
}); | ||
it('sends the request body', function(done) { | ||
it('sends the request body', function() { | ||
heroku.apps('my-app').addons('papertrail:choklad').update({ name: 'papertrail:fixa' }, function() { | ||
expect(client.request.mostRecentCall.args[0].body).toEqual({ name: 'papertrail:fixa' }); | ||
done(); | ||
expect(Request.request.mostRecentCall.args[0].body).toEqual({ name: 'papertrail:fixa' }); | ||
}); | ||
}); | ||
}); | ||
describe('#get', function() { | ||
it('does a GET request', function() { | ||
heroku.get('/apps', function () { | ||
expect(Request.request.mostRecentCall.args[0].method).toEqual('GET'); | ||
}); | ||
}); | ||
it('requests the specified path', function() { | ||
heroku.get('/apps', function () { | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
}); | ||
}); | ||
}); | ||
describe('#post', function() { | ||
it('does a POST request', function() { | ||
heroku.post('/apps', function () { | ||
expect(Request.request.mostRecentCall.args[0].method).toEqual('POST'); | ||
}); | ||
}); | ||
it('requests the specified path', function() { | ||
heroku.post('/apps', function () { | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
}); | ||
}); | ||
describe('when a body is supplied', function() { | ||
it('sends the request body', function() { | ||
heroku.post('/apps', { name: 'my-app' }, function () { | ||
expect(Request.request.mostRecentCall.args[0].body).toEqual({ name: 'my-app' }); | ||
}); | ||
}); | ||
}); | ||
}); | ||
describe('#patch', function() { | ||
it('does a PATCH request', function() { | ||
heroku.patch('/apps', function () { | ||
expect(Request.request.mostRecentCall.args[0].method).toEqual('PATCH'); | ||
}); | ||
}); | ||
it('requests the specified path', function() { | ||
heroku.patch('/apps', function () { | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps'); | ||
}); | ||
}); | ||
describe('when a body is supplied', function() { | ||
it('sends the request body', function() { | ||
heroku.patch('/apps', { name: 'my-app' }, function () { | ||
expect(Request.request.mostRecentCall.args[0].body).toEqual({ name: 'my-app' }); | ||
}); | ||
}); | ||
}); | ||
}); | ||
describe('#delete', function() { | ||
it('does a DELETE request', function() { | ||
heroku.delete('/apps/my-app', function () { | ||
expect(Request.request.mostRecentCall.args[0].method).toEqual('DELETE'); | ||
}); | ||
}); | ||
it('requests the specified path', function() { | ||
heroku.delete('/apps/my-app', function () { | ||
expect(Request.request.mostRecentCall.args[0].path).toEqual('/apps/my-app'); | ||
}); | ||
}); | ||
}); | ||
}); |
var https = require("https"), | ||
client = require('../../lib/request'), | ||
encryptor = require('../../lib/encryptor'); | ||
Request = require('../../lib/request'), | ||
memjs = require('memjs'), | ||
@@ -104,3 +105,4 @@ MockCache = require('../helpers/mockCache'), | ||
'Accept': 'application/vnd.heroku+json; version=3', | ||
'Content-type': 'application/json' | ||
'Content-type': 'application/json', | ||
'Range': 'id ]..; max=1000' | ||
} | ||
@@ -116,3 +118,3 @@ | ||
describe('status codes', function() { | ||
it('expects a 200 response by default', function(done) { | ||
it('expects a 2xx response by default', function(done) { | ||
makeRequest('/apps', {}, function(err) { | ||
@@ -123,30 +125,26 @@ expect(err.message).toEqual('Expected response to be successful, got 404'); | ||
}); | ||
}); | ||
it('accepts a single expected status code', function(done) { | ||
makeRequest('/apps', { expectedStatus: 201 }, function(err, body) { | ||
expect(err.message).toEqual('Expected response 201, got 200'); | ||
done(); | ||
describe('handling Range headers', function() { | ||
it('sends a default Range header', function() { | ||
makeRequest('/apps', {}, function (err, body) { | ||
expect(https.request.mostRecentCall.args[0].headers['Range']).toEqual('id ]..; max=1000'); | ||
}); | ||
}); | ||
it('accepts an array of expected status codes (failure)', function(done) { | ||
makeRequest('/apps', { expectedStatus: [201, 204] }, function(err, body) { | ||
expect(err.message).toEqual('Expected response [201,204], got 200'); | ||
done(); | ||
}, { response: { statusCode: 200 } }); | ||
}); | ||
describe('when receiving a Next-Range header', function() { | ||
it('sends the Next-Range header on the next request', function(done) { | ||
makeRequest('/apps', {}, function (err, body) { | ||
expect(https.request.mostRecentCall.args[0].headers['Range']).toEqual('id abcdefg..; max=1000'); | ||
done(); | ||
}, { response: { headers: { 'next-range': 'id abcdefg..; max=1000' } } }); | ||
}); | ||
it('accepts an array of expected status codes (success, first)', function(done) { | ||
makeRequest('/apps', { expectedStatus: [201, 204] }, function(err, body) { | ||
expect(err).toEqual(null); | ||
done(); | ||
}, { response: { statusCode: 201 } }); | ||
it('aggregates response bodies', function(done) { | ||
makeRequest('/apps', {}, function (err, body) { | ||
expect(body).toEqual([{ message: 'ok' }, { message: 'ok' }]); | ||
done(); | ||
}, { returnArray: true, response: { headers: { 'next-range': 'id abcdefg..; max=1000' } } }); | ||
}); | ||
}); | ||
it('accepts an array of expected status codes (success, last)', function(done) { | ||
makeRequest('/apps', { expectedStatus: [201, 204] }, function(err, body) { | ||
expect(err).toEqual(null); | ||
done(); | ||
}, { response: { statusCode: 204 } }); | ||
}); | ||
}); | ||
@@ -157,5 +155,7 @@ | ||
process.env.HEROKU_CLIENT_ENCRYPTION_SECRET = 'abcde'; | ||
beforeEach(function() { | ||
spyOn(memjs.Client, 'create').andReturn(cache); | ||
client.connectCacheClient(); | ||
Request.connectCacheClient(); | ||
}); | ||
@@ -173,4 +173,4 @@ | ||
makeRequest('/apps', { cacheKeyPostfix: '123' }, function(err, body) { | ||
expect(cache.get).toHaveBeenCalledWith('/apps-123', jasmine.any(Function)); | ||
makeRequest('/apps', { token: 'api-token' }, function(err, body) { | ||
expect(cache.get).toHaveBeenCalledWith(encryptor.encrypt('/appsid ]..; max=1000api-token'), jasmine.any(Function)); | ||
done(); | ||
@@ -190,3 +190,3 @@ }); | ||
makeRequest('/apps', { cacheKeyPostfix: '123' }, function(err, body) { | ||
makeRequest('/apps', { token: 'api-token' }, function(err, body) { | ||
var expectedCache = JSON.stringify({ | ||
@@ -197,3 +197,3 @@ body: { message: 'ok' }, | ||
expect(cache.set).toHaveBeenCalledWith('/apps-123', expectedCache); | ||
expect(cache.set).toHaveBeenCalledWith(encryptor.encrypt('/appsid ]..; max=1000api-token'), encryptor.encrypt(expectedCache)); | ||
done(); | ||
@@ -209,10 +209,19 @@ }, { response: { headers: { etag: '123' } } }); | ||
spyOn(https, 'request').andCallFake(function(options, httpsCallback) { | ||
var req = new MockRequest(); | ||
var res = new MockResponse(testOptions.response || {}); | ||
spyOn(https, 'request').andCallFake(function (options, httpsCallback) { | ||
if (options.headers.Range !== 'id ]..; max=1000') { | ||
testOptions.response.headers['next-range'] = undefined; | ||
} | ||
var req = new MockRequest(), | ||
res = new MockResponse(testOptions.response || {}); | ||
httpsCallback(res); | ||
setTimeout(function() { | ||
res.emit('data', '{ "message": "ok" }'); | ||
setTimeout(function () { | ||
if (testOptions.returnArray) { | ||
res.emit('data', '[{ "message": "ok" }]'); | ||
} else { | ||
res.emit('data', '{ "message": "ok" }'); | ||
} | ||
if (!req.isAborted) res.emit('end'); | ||
@@ -225,5 +234,5 @@ }, testOptions.timeout || 0); | ||
return client.request(options, function(err, body) { | ||
return Request.request(options, function (err, body) { | ||
if (callback) callback(err, body); | ||
}); | ||
}; |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
167779
45
4495
1
181
5
5
3
+ Addedconcat-stream@~1.1.0
+ Addedpath-proxy@~1.0
+ Addedbase64-js@0.0.2(transitive)
+ Addedbops@0.0.6(transitive)
+ Addedconcat-stream@1.1.0(transitive)
+ Addedinflection@1.3.8(transitive)
+ Addedpath-proxy@1.0.0(transitive)
+ Addedto-utf8@0.0.1(transitive)
- Removedunderscore@~1.5.1
- Removedunderscore@1.5.2(transitive)