-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathapi-easy.js
More file actions
602 lines (548 loc) · 20.3 KB
/
api-easy.js
File metadata and controls
602 lines (548 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
/*
* api-easy.js: Top-level include for the api-easy module.
*
* (C) 2011, Nodejitsu Inc.
*
*/
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`
//
require('pkginfo')(module, '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;
this.timeout = options.timeout || 0;
//
// **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));
},
options: function (/* [uri, data, params] */) {
var args = Array.prototype.slice.call(arguments);
return this._request.apply(this, ['options'].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) {
if (err) {
callback(err);
} else {
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.');
}
var withDebugInfo = function withDebugInfo(fun) {
return function(err, res, body) {
try {
return fun(err, res, body);
} catch (e) {
throw new Error(e.toString() + '\n\nContext:\n' + context.debugInfo.method.toUpperCase() + ' ' + context.debugInfo.uri + '\n\n' + context.debugInfo.stacktrace);
}
};
};
// Setup the custom test assertion if we have the appropriate arguments.
if (text && test) {
context[text] = withDebugInfo(function (err, res, body) {
assert.ifError(err);
test.apply(context, arguments);
});
}
// Setup the response code test assertion if we have the appropriate arguments.
if (code) {
context['should respond with ' + code] = withDebugInfo(function (err, res, body) {
assert.ifError(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)] = withDebugInfo(function (err, res, body) {
//
// Pass any and all errors from parsing and asserting
// the JSON returned to the underlying `vows` suite.
//
assert.ifError(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;
outgoing.timeout = this.timeout;
//
// 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();
var debugInfo = {
uri: outgoing.uri,
method: outgoing.method,
stacktrace: new Error('original stacktrace').stack
};
if (context[this.current]) {
console.log('\nWARNING: A previous test has been overwritten. Is that what you intended?\n' +
'(Hint: To avoid that error, use "next" to start a new batch)\n\n' +
'Context: ' + this.current + '\n\n' + debugInfo.stacktrace);
}
//
// 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);
},
debugInfo: debugInfo
};
//
// 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('/');
}