api-easy.js | |
---|---|
/*
* api-easy.js: Top-level include for the api-easy module.
*
* (C) 2011, Charlie Robbins
*
*/
var qs = require('querystring'),
request = require('request'),
vows = require('vows'),
assert = require('assert'); | |
Version 0.2.0 // 03/22/2011 | exports.version = [0, 2, 0]; |
APIeasy.describe(text)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 | exports.describe = function (text) {
return { |
State / Context management:
| suite: vows.describe(text),
discussion: [],
outgoing: {
headers: {}
},
befores: {},
options: {},
paths: [],
batch: {},
batches: [],
|
Add and Remove BDD DiscussionSimple 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.: | 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 / OptionsConfigure the remote | 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; |
TODO (indexzero): Setup | return this;
},
|
Configure HeadersManipulate 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;
},
removeHeader: function (key, value) {
delete this.outgoing.headers[key];
return this;
},
|
Manipulate Base PathControl the base path used for a given test in this suite. Append a path
by calling | 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 OptionsOften it is necessary to set some HTTP options conditionally or based on
results of a dynamic and/or asynchronous operation. A call to | before: function (name, fn) {
this.befores[name] = fn;
return this;
},
unbefore: function (name) {
delete this.befores[name];
return this;
},
|
Add HTTP Request-based TestsThe Each method invocation returns the suite itself so that
| 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));
},
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));
},
|
Add Test AssertionsAdd test assertions with
| 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) {
try {
assert.isNull(err);
var testResult = JSON.parse(body);
assert.deepEqual(result, testResult);
}
catch (ex) { |
In the case of a JSON parse exception we need to alert the vows suite. Not quite sure the best approach here. cloudhead? TODO (indexzero): Something better here | assert.equal('Error parsing JSON returned', 'There was an');
}
};
}
return this;
},
|
Create some helper methods for setting important options
that will be later passed to | followRedirect: function (follow) {
this.outgoing.followRedirect = follow;
return this;
},
maxRedirects: function (max) {
this.outgoing.maxRedirects = max;
return this;
},
|
Perform Sequential Tests EasilySince 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: function () {
this.suite.addBatch(this.batch);
this.batches.push(this.batch);
this.batch = {};
this.current = '';
return this;
},
|
Run Your TestsAgain, 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 You can also call | export: function (target) {
if (this.batch) {
this.next();
}
this.suite.export(target);
return this;
},
run: function (options, callback) {
if (this.batch) {
this.next();
}
if (!callback) {
callback = options;
options = {};
}
this.suite.run(options, callback);
return this;
},
|
Helpers and Utilities
| _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(),
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 | if (params) {
fullUri += '?' + qs.stringify(params);
}
|
If the user has provided data, assume that it is JSON
and set it to the TODO (indexzero): Expose more properties available by the request module | if (data) {
if (this.outgoing.headers['Content-Type'] == 'application/x-www-form-urlencoded') {
outgoing.body = qs.stringify(data);
}
else {
outgoing.body = JSON.stringify(data);
}
}
|
Set the | outgoing.uri = this.secure ? 'https://' : 'http://';
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);
});
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 | 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 | 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 | function distillPath (paths) {
return '/' + paths.map(function (p) {
return p.replace(/^\/|\/$/ig, '');
}).join('/');
}
|