api-easy
Version:
Fluent (i.e. chainable) syntax for generating vows tests against RESTful APIs.
569 lines (518 loc) • 19.2 kB
JavaScript
/*
* api-easy.js: Top-level include for the api-easy module.
*
* (C) 2011, Charlie Robbins & the Contributors.
*
*/
var assert = require('assert'),
fs = require('fs'),
path = require('path'),
qs = require('qs'),
request = require('request'),
vows = require('vows');
//
// ### Check for version info in `package.json`
//
exports.version = require('../package.json').version;
//
// ## APIeasy.describe(text, vowsSuite)
// This is the main (and sole) entry point for APIeasy.
// It responds with an object literal that manages an
// underlying vows suite. Each call to `APIeasy.describe()`
// will create a vows suite, with the corresponding `text`
// passed to this method.
//
exports.describe = function (text) {
return {
//
// ### State / Context management:
//
// * `suite`: The underlying vows suite
// * `discussion`: Ordered list containing the set of text to use before each test
// * `outgoing`: Shared options to be passed to the `request` module on each test.
// * `befores`: Mapping of named functions to execute before each test to modify the
// outgoing request options.
// * `options`: Various configuration options for managing nuances of state / scope.
// * `paths`: The set of paths representing the location of the current resource /
// API method being tested by this object.
// * `batch`: The object literal representing the current batch of vows tests to
// eventually be pass to vows `.addBatch()`.
// * `batches`: The set of all batches that have been added to the vows `suite`
// associated with this object.
//
suite: vows.describe(text),
discussion: [],
outgoing: {
headers: {}
},
befores: {},
options: {},
paths: [],
batch: {},
batches: [],
//
// ### Add and Remove BDD Discussion
// Simple pathing for adding contextual description to sets of tests.
// Each call to discuss will create an object in the nested vows
// structure which has that text as the key in the parent. **e.g.:**
//
// APIeasy.describe('your/awesome/api')
// .use('localhost', 8080)
// .discuss('When using your awesome API')
// .discuss('and an awesome resource')
// .path('/awesome-resource')
// .get().expect(200)
// .undiscuss().unpath()
// .discuss('and a super resource')
// .path('/super-resource')
// .get().expect(404);
//
discuss: function (text) {
this.discussion.push(text);
return this;
},
undiscuss: function (length) {
length = length || 1;
this.discussion.splice(-1 * length, length);
return this;
},
//
// ### Setup Remote API Location / Options
// Configure the remote `host`, `port`, and miscellaneous
// `options` of the API that this suite is testing.
//
use: function (host /* [port, options] */) {
var args = Array.prototype.slice.call(arguments),
options = typeof args[args.length - 1] === 'object' ? args.pop() : {},
port = args[1];
this.host = host || 'localhost';
this.port = port || 80;
this.secure = options.secure || false;
this.auth = options.auth;
//
// **TODO _(indexzero)_:** Setup `this.options` here (i.e. options for the SUITE, not the REQUEST)
// _What are useful options to expose?_
//
return this;
},
//
// ### Configure Basic Authentication
//
authenticate: function (username, password) {
this.auth = password ? (username + ':' + password) : username;
return this;
},
unauthenticate: function () {
this.auth = null;
return this;
},
//
// ### Configure Headers
// Manipulate the HTTP headers that are sent to your API using these methods.
// They are designed to mimic the node.js core HTTP APIs.
//
setHeaders: function (headers) {
this.outgoing.headers = headers;
return this;
},
setHeader: function (key, value) {
this.outgoing.headers[key] = value;
return this;
},
getHeader: function (key) {
return this.outgoing.headers[key];
},
removeHeader: function (key, value) {
delete this.outgoing.headers[key];
return this;
},
//
// ### Manipulate Base Path
// Control the base path used for a given test in this suite. Append a path
// by calling `.path()`. Remove the last `num` paths from the suite by calling
// `.unpath(num)`. Set the root path using `.root(path)`
//
path: function (uri) {
this.paths.push(uri.replace(/^\/|\/$/ig, ''));
return this;
},
unpath: function (length) {
length = length || 1;
this.paths.splice(-1 * length, length);
return this;
},
root: function (path) {
this.paths = [path];
return this;
},
//
// ### Dynamically set Outgoing Request Options
// Often it is necessary to set some HTTP options conditionally or based on
// results of a dynamic and/or asynchronous operation. A call to `.before()`
// will enqueue a function that will modify the outgoing request options
// before the request is made for all tests on the suite.
//
before: function (name, fn) {
this.befores[name] = fn;
return this;
},
unbefore: function (name) {
delete this.befores[name];
return this;
},
//
// ### Add HTTP Request-based Tests
// The `.get()`, `.post()`, `.put()`, `.del()`, `.patch()` and `.head()`
// methods add a new context and topic to the vows structure maintained
// by this APIeasy suite. The nuts and bolts of this are in the "private"
// method `_request()`.
//
// Each method invocation returns the suite itself so that
//`.expect()` and other assertion method(s) can be called
// afterwards to add assertions to this context.
//
get: function (/* [uri, params] */) {
var args = Array.prototype.slice.call(arguments);
args.splice(1, -1, null);
return this._request.apply(this, ['get'].concat(args));
},
post: function (/* [uri, data, params] */) {
var args = Array.prototype.slice.call(arguments);
return this._request.apply(this, ['post'].concat(args));
},
put: function (/* [uri, data, params] */) {
var args = Array.prototype.slice.call(arguments);
return this._request.apply(this, ['put'].concat(args));
},
patch: function (/* [uri, data, params] */) {
var args = Array.prototype.slice.call(arguments);
var req = this._request.apply(this, ['patch'].concat(args));
return req;
},
del: function (/* [uri, data, params] */) {
var args = Array.prototype.slice.call(arguments);
return this._request.apply(this, ['delete'].concat(args));
},
head: function (/* [uri, params] */) {
var args = Array.prototype.slice.call(arguments);
args.splice(1, -1, null);
return this._request.apply(this, ['head'].concat(args));
},
uploadFile: function (/* [uri, filepath, filePartName, data] */) {
var args = Array.prototype.slice.call(arguments),
filepath = args.splice(1, 1),
filePartName = args.splice(1, 1),
data = args.splice(1, 1),
filename = path.basename(filepath);
args.push(function (outgoing, callback) {
//
// TODO replace request/multipart with better implementation with
// low memory consumption
//
fs.readFile(filepath[0], function (err, fileData) {
var multipart = outgoing.multipart = [];
if(data && data.length){
Object.keys(data[0]).forEach(function(key){
var value = data[0][key];
multipart.push({
'Content-Disposition': 'form-data; name="' + key + '"',
body: value
});
});
}
multipart.push({
'content-type': 'application/octet-stream',
'Content-Transfer-Encoding': 'binary',
'Content-Disposition': 'form-data; name="' + filePartName + '"; filename="' + filename + '"',
'body': fileData
});
request(outgoing, callback);
});
});
return this._request.apply(this, ['post'].concat(args));
},
//
// ### Add Test Assertions
// Add test assertions with `.expect()`. There are a couple of options here:
//
// 1. Assert a response code: `suite.expect(200)`
// 2. Assert a JSON result: `suite.expect({ some: 'value' })`
// 3. Use a custom assertion: `suite.expect('should be custom', function (err, res, body) { ... })`
//
expect: function (/* [text, code, result, assert] */) {
var args = Array.prototype.slice.call(arguments),
text, code, result, test, context;
args.forEach(function (arg) {
switch (typeof(arg)) {
case 'number': code = arg; break;
case 'string': text = arg; break;
case 'object': result = arg; break;
case 'function': test = arg; break;
}
});
context = this._currentTest(this.current);
// When using a custom test assertion function, both the assertion function
// and a description are required or else we have no key in the JSON structure to use.
if (text && !test || test && !text) {
throw new Error('Both description and a custom test are required.');
}
// Setup the custom test assertion if we have the appropriate arguments.
if (text && test) {
context[text] = function (err, res, body) {
assert.isNull(err);
test.apply(context, arguments);
};
}
// Setup the response code test assertion if we have the appropriate arguments.
if (code) {
context['should respond with ' + code] = function (err, res, body) {
assert.isNull(err);
assert.equal(res.statusCode, code);
};
}
// Setup the JSON response assertion if we have the appropriate arguments.
if (result) {
context['should respond with ' + JSON.stringify(result).substring(0, 50)] = function (err, res, body) {
//
// Pass any and all errors from parsing and asserting
// the JSON returned to the underlying `vows` suite.
//
assert.isNull(err);
var testResult = JSON.parse(body);
assert.deepEqual(testResult, result);
};
}
return this;
},
//
// Create some helper methods for setting important options
// that will be later passed to `request`.
//
followRedirect: function (follow) {
this.outgoing.followRedirect = follow;
return this;
},
maxRedirects: function (max) {
this.outgoing.maxRedirects = max;
return this;
},
//
// ### Perform Sequential Tests Easily
// Since this object literal is designed to manage a single vows suite,
// we need a way to add multiple batches to that suite for performing
// sequential tests. This is precisely what `.next()` does. It will:
//
// 1. Add the current batch (or 'vows'), `this.batch`, to the vows suite
// 2. Add this same batch to the set of batches on `this.batches`
// 3. Create a new empty object literal to use for `this.batch`.
// 4. Reset the context for the `this.current` test.
//
next: function () {
this.suite.addBatch(this.batch);
this.batches.push(this.batch);
this.batch = {};
this.current = '';
return this;
},
//
// ### Run Your Tests
// Again, since we are managing a single vows suite in this object we
// should expose an easy way to export your tests to a given target without
// needing to call `apiEasySuite.suite.export(module)`. You should only
// call this method once in a given test file.
//
// The method exportTo(module) is provided as an alias to export(module)
// if you want to avoid using the reserved JavaScript `export` word
//
// You can also call `.run()` which will run the specified suite just
// as if you were using vows directly.
//
export: function (target) {
if (this.batch) {
this.next();
}
this.suite.export(target);
return this;
},
exportTo: function (target) {
return this.export(target);
},
run: function (options, callback) {
if (this.batch) {
this.next();
}
if (!callback) {
callback = options;
options = {};
}
this.suite.run(options, callback);
return this;
},
// ### Use Vows from APIeasy
//extrapolating this allows us to add our own custom methods.
inheritedMethods: ['get', 'post', 'del', 'put', 'patch', 'head', 'uploadFile'],
addBatch : function () {
if (this.batch) {
this.next();
}
//
// injects `easy` methods into vows' suite to be able
// to switch back to APIEasy context
//
var self = this;
this.inheritedMethods.forEach(function (methodName) {
if (typeof self.suite[methodName] === 'undefined') {
self.suite[methodName] = function () {
return self[methodName].apply(self, arguments);
}
}
});
this.suite.addBatch.apply(this.suite, arguments);
return this;
},
//
// ### Helpers and Utilities
// `_request()` exists for the sake of DRY and simplicity and is designed to handle
// a variety of interal usage(s) exposed indirectly through the `.get()`,
// `.post()`, `.put()`, `.del()` and `.head()`. Nothing to see here unless you're
// interested in improving APIeasy itself.
//
_request: function (/* method [uri, data, params] */) {
var self = this,
args = Array.prototype.slice.call(arguments),
method = args.shift(),
uri = typeof args[0] === 'string' && args.shift(),
data = typeof args[0] === 'object' && args.shift(),
params = typeof args[0] === 'object' && args.shift(),
// custom request implementation function (outgoing, callaback),
// should invoke callback(err, response, body) once done
requestImpl = typeof args[0] === 'function' && args.shift(),
port = this.port && this.port !== 80 ? ':' + this.port : '',
outgoing = clone(this.outgoing),
fullUri, context;
//
// Update the fullUri for this request with the passed uri
// and the query string parameters (if any).
//
fullUri = distillPath(uri ? this.paths.concat([uri]) : this.paths);
//
// Append the query string parameters to the `fullUri`. It's worth mentioning
// here that if only a single object is provided to `_request()` it will assume
// that it is the request body, not the params hash.
//
if (params) {
fullUri += '?' + qs.stringify(params);
}
//
// If the user has provided data, assume that it is JSON
// and set it to the `body` property of the options.
//
// **TODO _(indexzero)_**: Expose more properties available by the
// [request module](http://github.com/mikeal/request)
//
if (data) {
if (this.outgoing.headers['Content-Type'] == 'application/x-www-form-urlencoded') {
outgoing.body = qs.stringify(data);
}
else if (this.outgoing.headers['Content-Type'] == 'application/json') {
outgoing.body = JSON.stringify(data);
}
else {
outgoing.body = data;
}
}
//
// Set the `uri` and `method` properties of the request options `outgoing`
// using the information provided to this instance and `_request()`.
//
outgoing.uri = this.secure ? 'https://' : 'http://';
outgoing.uri += this.auth ? this.auth + '@' : '';
outgoing.uri += this.host + port + fullUri;
outgoing.method = method;
//
// Create the description for this test. This is currently static.
// **Remark _(indexzero)_**: Do users care if these strings are configurable?
//
this.current = ['A', method.toUpperCase(), 'to', fullUri].join(' ');
context = this._currentTest();
//
// Add the topic for the specified request to the context of the current
// batch used by this suite.
//
context[this.current] = {
topic: function () {
//
// Before making the outgoing HTTP request for this topic, execute
// all known before funtions available to this suite. These functions
// are by definition synchronous add vows before a given test if
// this data is fetched asynchronously.
//
Object.keys(self.befores).forEach(function (name) {
outgoing = self.befores[name](outgoing);
});
if (requestImpl)
requestImpl(outgoing, this.callback);
else
request(outgoing, this.callback);
}
};
//
// Set the outgoing request options and set of before functions on the topic.
// This is used for test assertions, general consistency, and basically
// just knowing what every topic does explicitly.
//
context[this.current].topic.outgoing = outgoing;
context[this.current].topic.before = this.befores;
return this;
},
//
// The vows data structure is read as a sentence constructred by
// keys in a nested JSON structure. This helper method is designed to
// get the current test context (i.e. object) by nesting into the
// JSON structure using this convention.
//
_currentTest: function (text) {
var last = this.batch;
// Nest into the batch JSON structure using the current `discussion` text.
this.discussion.forEach(function (text) {
if (typeof last[text] !== 'object') {
last[text] = {};
}
// Capture the nested object
last = last[text];
});
return text ? last[text] : last;
}
};
};
//
// A simple function that performs a deep clone on the specified `obj`.
// We use this in APIeasy to create multiple copies of the `options`
// passed to `request` because they are considered mutable by `request`
// and we strive to make each request idempotent.
//
function clone (obj) {
var copy = {};
for (var i in obj) {
if (Array.isArray(obj[i])) {
copy[i] = obj[i].slice(0);
}
else {
copy[i] = obj[i] instanceof Object ? clone(obj[i]) : obj[i];
}
}
return copy;
}
//
// Helper function used to join nested paths created by
// multiple calls to `.path()`.
//
// suite.path('/a-path')
// .path('/hey-another-path')
// .path(...)
//
function distillPath (paths) {
return '/' + paths.map(function (p) {
return encodeURI(p.replace(/^\/|\/$/ig, ''));
}).join('/');
}