├── .npmignore ├── .jshintignore ├── .gitignore ├── test ├── jshint.spec.js ├── mocha.opts ├── fixtures │ ├── users.js │ └── albums.js ├── .jshintrc ├── common.js ├── unit │ └── json.spec.js ├── server.js └── integration │ ├── multifetch.options.spec.js │ └── multifetch.spec.js ├── .travis.yml ├── package.json ├── source ├── nullify.js ├── json.js ├── index.js └── http.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.DS_Store 3 | node_modules 4 | -------------------------------------------------------------------------------- /test/jshint.spec.js: -------------------------------------------------------------------------------- 1 | require('mocha-jshint')(); 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "0.10" 5 | before_script: 6 | - npm install 7 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/common.js 2 | --reporter spec 3 | --ui bdd 4 | --recursive 5 | --colors 6 | --timeout 5000 7 | --slow 100 8 | -------------------------------------------------------------------------------- /test/fixtures/users.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | name: 'user_1', 4 | associates: [], 5 | location: { 6 | city: 'Copenhagen', 7 | address: 'Wildersgade' 8 | } 9 | }, 10 | { 11 | name: 'user_2', 12 | associates: ['user_1', 'user_3'], 13 | location: { 14 | city: 'Aarhus', 15 | address: 'Niels Borhs Vej' 16 | } 17 | }, 18 | { 19 | name: 'user_3', 20 | associates: ['user_2'], 21 | location: { 22 | city: 'Aarhus', 23 | address: 'Hovedgade' 24 | } 25 | } 26 | ]; 27 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "chai", 4 | "sinon", 5 | "before", 6 | "after", 7 | "it", 8 | "describe", 9 | "expect", 10 | "beforeEach", 11 | "afterEach", 12 | "helper" 13 | ], 14 | "asi" : false, 15 | "bitwise" : true, 16 | "boss" : false, 17 | "curly" : true, 18 | "debug": false, 19 | "devel": false, 20 | "eqeqeq": true, 21 | "evil": true, 22 | "expr": true, 23 | "forin": false, 24 | "immed": true, 25 | "latedef" : false, 26 | "laxbreak": false, 27 | "multistr": true, 28 | "newcap": true, 29 | "noarg": true, 30 | "node" : true, 31 | "noempty": false, 32 | "nonew": true, 33 | "onevar": false, 34 | "plusplus": false, 35 | "regexp": false, 36 | "strict": false, 37 | "sub": false, 38 | "trailing" : true, 39 | "undef": true, 40 | "unused": "vars" 41 | 42 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multifetch", 3 | "version": "1.1.0", 4 | "repository": "git@github.com:debitoor/multifetch.git", 5 | "description": "Express middleware for performing internal batch requests", 6 | "main": "source/index.js", 7 | "scripts": { 8 | "test": "mocha" 9 | }, 10 | "dependencies": { 11 | "async": "~0.9.0", 12 | "pump": "~1.0.0", 13 | "extend": "~2.0.0" 14 | }, 15 | "devDependencies": { 16 | "request": "~2.30.0", 17 | "express": "~4.10.1", 18 | "mocha": "~1.15.1", 19 | "chai": "~1.8.1", 20 | "sinon": "~1.7.3", 21 | "sinon-chai": "~2.4.0", 22 | "mocha-jshint": "~0.0.7", 23 | "once": "~1.3.0", 24 | "method-override": "~2.3.0", 25 | "body-parser": "~1.9.2" 26 | }, 27 | "license": "MIT", 28 | "keywords": [ 29 | "express", 30 | "middleware", 31 | "batch", 32 | "multi", 33 | "request" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /source/nullify.js: -------------------------------------------------------------------------------- 1 | var stream = require('stream'); 2 | var util = require('util'); 3 | 4 | var isJson = function(response) { 5 | var type = response.getHeader('Content-Type') || ''; 6 | return (/(text|application)\/json/).test(type); 7 | }; 8 | 9 | var NullifyStream = function(response) { 10 | if(!(this instanceof NullifyStream)) { 11 | return new NullifyStream(response); 12 | } 13 | 14 | stream.Transform.call(this); 15 | 16 | this._response = response; 17 | this._started = false; 18 | this._nullify = false; 19 | this._destroyed = false; 20 | }; 21 | 22 | util.inherits(NullifyStream, stream.Transform); 23 | 24 | NullifyStream.prototype._transform = function(data, encoding, callback) { 25 | if(this._nullify) { 26 | return callback(); 27 | } 28 | if(!this._started && !isJson(this._response)) { 29 | this._started = true; 30 | this._nullify = true; 31 | 32 | return callback(null, 'null'); 33 | } 34 | 35 | this._started = true; 36 | callback(null, data); 37 | }; 38 | 39 | NullifyStream.prototype.destroy = function() { 40 | if(this._destroyed) { 41 | return; 42 | } 43 | 44 | this._destroyed = true; 45 | this.emit('close'); 46 | }; 47 | 48 | module.exports = NullifyStream; 49 | -------------------------------------------------------------------------------- /test/fixtures/albums.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | owner: 'user_1', 4 | name: 'album_1', 5 | date: '2013-12-12', 6 | files: [{ 7 | name: 'file_1', 8 | size: 128 9 | }, { 10 | name: 'file_2', 11 | size: 512 12 | }] 13 | }, 14 | { 15 | owner: 'user_1', 16 | name: 'album_2', 17 | date: '2013-12-12', 18 | files: [{ 19 | name: 'file_1', 20 | size: 128 21 | }] 22 | }, 23 | { 24 | owner: 'user_2', 25 | name: 'album_1', 26 | date: '2013-12-01', 27 | files: [{ 28 | name: 'file_1', 29 | size: 512 30 | }] 31 | }, 32 | { 33 | owner: 'user_3', 34 | name: 'album_3', 35 | date: '2013-11-29', 36 | files: [{ 37 | name: 'file_2', 38 | size: 1024 39 | }] 40 | }, 41 | { 42 | owner: 'user_3', 43 | name: 'album_4', 44 | date: '2013-12-01', 45 | files: [{ 46 | name: 'file_3', 47 | size: 512 48 | }] 49 | }, 50 | { 51 | owner: 'user_3', 52 | name: 'album_5', 53 | date: '2013-12-05', 54 | files: [{ 55 | name: 'file_3', 56 | size: 1024 57 | }, { 58 | name: 'file_4', 59 | size: 512 60 | }] 61 | }, 62 | { 63 | owner: 'user_3', 64 | name: 'album_6', 65 | date: '2013-12-05', 66 | files: [] 67 | }, 68 | { 69 | owner: 'user_3', 70 | name: 'album_7', 71 | date: '2013-12-06', 72 | files: [{ 73 | name: 'file_4', 74 | size: 512 75 | }, { 76 | name: 'file_5', 77 | size: 512 78 | }, { 79 | name: 'file_6', 80 | size: 1024 81 | }] 82 | } 83 | ]; -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | /*jshint -W079 */ 2 | process.env.NODE_ENV = process.env.NODE_ENV || 'test'; 3 | 4 | var path = require('path'); 5 | var util = require('util'); 6 | 7 | var sinon = require('sinon'); 8 | var chai = require('chai'); 9 | var once = require('once'); 10 | 11 | require('mocha'); 12 | 13 | var server = require('./server'); 14 | 15 | global.sinon = sinon; 16 | global.chai = chai; 17 | 18 | chai.Assertion.includeStack = true; 19 | chai.use(require('sinon-chai')); 20 | 21 | var PORT = 10808; 22 | 23 | chai.Assertion.addChainableMethod('subset', function(expected) { 24 | var actual = this.__flags.object; 25 | 26 | var actualJson = JSON.stringify(actual); 27 | var expectedJson = JSON.stringify(expected); 28 | 29 | this.assert( 30 | sinon.match(expected).test(actual), 31 | util.format('expected %s to contain subset %s', actualJson, expectedJson), 32 | util.format('expected %s not to contain subset %s', actualJson, expectedJson), 33 | expected); 34 | }); 35 | 36 | var helper = function() { 37 | var that = {}; 38 | 39 | var url = function(path) { 40 | return 'http://localhost:' + PORT + (path || '/'); 41 | }; 42 | 43 | var requireSource = function(module) { 44 | return require(path.join(__dirname, '..', 'source', module)); 45 | }; 46 | 47 | var readStream = function(stream, callback) { 48 | callback = once(callback); 49 | 50 | var buffer = []; 51 | 52 | stream.on('data', function(data) { 53 | buffer.push(data); 54 | }); 55 | stream.on('end', function() { 56 | var json = Buffer.concat(buffer).toString('utf-8'); 57 | json = JSON.parse(json); 58 | 59 | callback(null, json); 60 | }); 61 | stream.on('close', function() { 62 | callback(new Error('Stream closed')); 63 | }); 64 | stream.on('error', function(err) { 65 | callback(err); 66 | }); 67 | }; 68 | 69 | that.url = url; 70 | that.server = server; 71 | that.port = PORT; 72 | 73 | that.requireSource = requireSource; 74 | that.readStream = readStream; 75 | 76 | return that; 77 | }; 78 | 79 | global.helper = helper(); 80 | -------------------------------------------------------------------------------- /source/json.js: -------------------------------------------------------------------------------- 1 | var stream = require('stream'); 2 | var util = require('util'); 3 | 4 | var noop = function() {}; 5 | 6 | var ObjectStream = function(parent) { 7 | stream.Writable.call(this); 8 | 9 | this._destroyed = false; 10 | this._parent = parent; 11 | }; 12 | 13 | util.inherits(ObjectStream, stream.Writable); 14 | 15 | ObjectStream.prototype._write = function(data, encoding, callback) { 16 | if(this._parent.push(data)) { 17 | callback(); 18 | } else { 19 | this._parent._callback = callback; 20 | } 21 | }; 22 | 23 | ObjectStream.prototype.destroy = function() { 24 | if(this._destroyed) { 25 | return; 26 | } 27 | 28 | this._destroyed = true; 29 | 30 | this.emit('close'); 31 | }; 32 | 33 | var JsonStream = function() { 34 | if(!(this instanceof JsonStream)) { 35 | return new JsonStream(); 36 | } 37 | 38 | stream.Readable.call(this); 39 | 40 | this._objectStream = null; 41 | this._destroyed = false; 42 | this._callback = noop; 43 | }; 44 | 45 | util.inherits(JsonStream, stream.Readable); 46 | 47 | JsonStream.prototype._read = function() { 48 | var cb = this._callback; 49 | this._callback = noop; 50 | cb(); 51 | }; 52 | 53 | JsonStream.prototype.end = function() { 54 | if(this._objectStream) { 55 | this.push('}'); 56 | } else { 57 | this.push('{}'); 58 | } 59 | 60 | this.push(null); 61 | }; 62 | 63 | JsonStream.prototype.destroy = function() { 64 | if(this._destroyed) { 65 | return; 66 | } 67 | 68 | this._destroyed = true; 69 | 70 | if(this._objectStream) { 71 | this._objectStream.destroy(); 72 | } 73 | 74 | this.emit('close'); 75 | }; 76 | 77 | JsonStream.prototype.createObjectStream = function(key) { 78 | if(this._objectStream) { 79 | this.push(','); 80 | } else { 81 | this.push('{'); 82 | } 83 | 84 | this.push(JSON.stringify(key) + ':'); 85 | this._objectStream = new ObjectStream(this); 86 | 87 | return this._objectStream; 88 | }; 89 | 90 | JsonStream.prototype.writeObject = function(key, obj) { 91 | var objectStream = this.createObjectStream(key); 92 | 93 | objectStream.write(JSON.stringify(obj)); 94 | objectStream.end(); 95 | }; 96 | 97 | module.exports = JsonStream; -------------------------------------------------------------------------------- /test/unit/json.spec.js: -------------------------------------------------------------------------------- 1 | var JsonStream = helper.requireSource('json'); 2 | 3 | describe('JsonStream', function() { 4 | var json; 5 | 6 | describe('write empty object', function() { 7 | beforeEach(function(done) { 8 | var jsonStream = new JsonStream(); 9 | 10 | helper.readStream(jsonStream, function(err, result) { 11 | json = result; 12 | done(err); 13 | }); 14 | 15 | jsonStream.end(); 16 | }); 17 | 18 | it('should write an empty object on no input', function() { 19 | chai.expect(json).to.deep.equal({}); 20 | }); 21 | }); 22 | 23 | describe('calling end on object stream should not end json stream', function() { 24 | var onEnd = sinon.spy(); 25 | 26 | beforeEach(function(done) { 27 | var jsonStream = new JsonStream(); 28 | var objectStream = jsonStream.createObjectStream('key'); 29 | 30 | jsonStream.on('end', onEnd); 31 | 32 | objectStream.on('finish', function() { 33 | done(); 34 | }); 35 | 36 | objectStream.write('null'); 37 | objectStream.end(); 38 | }); 39 | 40 | it('should not call end on json stream', function() { 41 | chai.expect(onEnd.called).to.be.false; 42 | }); 43 | }); 44 | 45 | describe('closing json stream should close object stream', function() { 46 | var onClose = sinon.spy(); 47 | 48 | beforeEach(function() { 49 | var jsonStream = new JsonStream(); 50 | var objectStream = jsonStream.createObjectStream('key'); 51 | 52 | objectStream.on('close', onClose); 53 | jsonStream.destroy(); 54 | }); 55 | 56 | it('should close object stream', function() { 57 | chai.expect(onClose.calledOnce).to.be.true; 58 | }); 59 | }); 60 | 61 | describe('stream single json object', function() { 62 | beforeEach(function(done) { 63 | var jsonStream = new JsonStream(); 64 | var objectStream = jsonStream.createObjectStream('key'); 65 | 66 | helper.readStream(jsonStream, function(err, result) { 67 | json = result; 68 | done(err); 69 | }); 70 | 71 | objectStream.on('finish', function() { 72 | jsonStream.end(); 73 | }); 74 | 75 | objectStream.write('{'); 76 | objectStream.write('"id"'); 77 | objectStream.write(':'); 78 | objectStream.write('1'); 79 | objectStream.write('}'); 80 | objectStream.end(); 81 | }); 82 | 83 | it('should contain valid json entry', function() { 84 | chai.expect(json).to.deep.equal({ key: { id: 1 } }); 85 | }); 86 | }); 87 | 88 | describe('stream multiple json values', function() { 89 | beforeEach(function(done) { 90 | var jsonStream = new JsonStream(); 91 | 92 | helper.readStream(jsonStream, function(err, result) { 93 | json = result; 94 | done(err); 95 | }); 96 | 97 | var objectStream1 = jsonStream.createObjectStream('key_1'); 98 | 99 | objectStream1.on('finish', function() { 100 | var objectStream2 = jsonStream.createObjectStream('key_2'); 101 | 102 | objectStream2.on('finish', function() { 103 | jsonStream.end(); 104 | }); 105 | 106 | objectStream2.write('['); 107 | objectStream2.write('2'); 108 | objectStream2.write(']'); 109 | objectStream2.end(); 110 | }); 111 | 112 | objectStream1.write('n'); 113 | objectStream1.write('u'); 114 | objectStream1.write('l'); 115 | objectStream1.write('l'); 116 | objectStream1.end(); 117 | }); 118 | 119 | it('should contain valid json', function() { 120 | chai.expect(json).to.deep.equal({ key_1: null, key_2: [2] }); 121 | }); 122 | }); 123 | 124 | describe('write json object', function() { 125 | beforeEach(function(done) { 126 | var jsonStream = new JsonStream(); 127 | 128 | helper.readStream(jsonStream, function(err, result) { 129 | json = result; 130 | done(err); 131 | }); 132 | 133 | jsonStream.writeObject('key', { id: 1 }); 134 | jsonStream.end(); 135 | }); 136 | 137 | it('should contain valid json', function() { 138 | chai.expect(json).to.deep.equal({ key: { id: 1 } }); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | var pump = require('pump'); 4 | var extend = require('extend'); 5 | var async = require('async'); 6 | 7 | var JsonStream = require('./json'); 8 | var NullifyStream = require('./nullify'); 9 | var http = require('./http'); 10 | 11 | var noopCallback = function(serverRequest, internalRequest, callback) { 12 | callback(); 13 | }; 14 | 15 | var createMessages = function(request, url) { 16 | var headers = { 17 | cookie: request.headers.cookie || '', 18 | accept: 'application/json' 19 | }; 20 | 21 | return http(request, { 22 | method: 'GET', 23 | url: url, 24 | headers: headers 25 | }); 26 | }; 27 | 28 | var getResources = function(request, ignore) { 29 | var body = (typeof request.body === 'object') ? request.body : {}; 30 | var query = extend({}, body, request.query); 31 | 32 | var path = url.parse(request.url).pathname; 33 | 34 | return Object.keys(query).reduce(function(acc, key) { 35 | if(query[key] !== path && ignore.indexOf(key) === -1) { 36 | acc[key] = query[key]; 37 | } 38 | 39 | return acc; 40 | }, {}); 41 | }; 42 | 43 | var fetchWithHeaders = function(request, response) { 44 | var json = new JsonStream(); 45 | var nullify = new NullifyStream(response); 46 | 47 | pump(response.socket.input, nullify, json.createObjectStream('body'), function(err) { 48 | if(err) { 49 | return json.destroy(); 50 | } 51 | 52 | json.writeObject('statusCode', response.statusCode); 53 | json.writeObject('headers', response._headers); 54 | json.end(); 55 | }); 56 | 57 | return json; 58 | }; 59 | 60 | var fetchBare = function(request, response) { 61 | var nullify = new NullifyStream(response); 62 | 63 | pump(response.socket.input, nullify, function(err) { 64 | if(err) { 65 | return nullify.destroy(); 66 | } 67 | }); 68 | 69 | return nullify; 70 | }; 71 | 72 | var endStream = function (jsonStream, error) { 73 | jsonStream.writeObject('_error', error); 74 | jsonStream.end(); 75 | }; 76 | 77 | var create = function(options, prefetch) { 78 | if(!prefetch && typeof options === 'function') { 79 | prefetch = options; 80 | options = {}; 81 | } 82 | 83 | options = options || {}; 84 | var ignore = options.ignore || []; 85 | var headers = options.headers !== undefined ? options.headers : true; 86 | var concurrency = options.concurrency || 1; // Defaults to sequential fetching 87 | 88 | var fetch = headers ? fetchWithHeaders : fetchBare; 89 | 90 | prefetch = prefetch || noopCallback; 91 | 92 | return function(request, response, next) { 93 | var app = request.app; 94 | var query = getResources(request, ignore); 95 | var keys = Object.keys(query); 96 | 97 | var json = new JsonStream(); 98 | var error = false; 99 | 100 | response.setHeader('Content-Type', 'application/json'); 101 | 102 | pump(json, response); 103 | 104 | // Exit early if there is nothing to fetch. 105 | if(keys.length === 0) { 106 | return endStream(json, error); 107 | } 108 | 109 | // The resource queue processes resource streams sequentially. 110 | var resourceQueue = async.queue(function worker(task, callback) { 111 | pump(task.resource, json.createObjectStream(task.key), function(err) { 112 | if(err) { 113 | json.destroy(); 114 | return callback(err); 115 | } 116 | if(!(/2\d\d/).test(task.response.statusCode)) { 117 | error = true; 118 | } 119 | callback(); 120 | }); 121 | }, 1); 122 | 123 | // Asynchronously fetch the resource for a key and push the resulting 124 | // stream into the resource queue. 125 | var fetchResource = function(key, callback) { 126 | var messages = createMessages(request, query[key]); 127 | prefetch(request, messages.request, function(prevent) { 128 | if (prevent) return callback(); 129 | 130 | var resource = fetch(messages.request, messages.response); 131 | var task = { 132 | resource: resource, 133 | request: messages.request, 134 | response: messages.response, 135 | key: key 136 | }; 137 | 138 | app(messages.request, messages.response, function() { 139 | resourceQueue.kill(); 140 | json.destroy(); 141 | }); 142 | 143 | // Callback is called once the stream for this resource has 144 | // been fully piped out to the client. 145 | resourceQueue.push(task, callback); 146 | }); 147 | }; 148 | 149 | // Fire off all requests and push the resulting streams into a queue to 150 | // be processed 151 | async.eachLimit(keys, concurrency, fetchResource, function(err) { 152 | if(resourceQueue.idle()) { 153 | endStream(json, error); 154 | } else { 155 | // Called once all streams have been fully pumped out to the client. 156 | resourceQueue.drain = function() { 157 | endStream(json, error); 158 | }; 159 | } 160 | }); 161 | }; 162 | }; 163 | 164 | module.exports = create; 165 | -------------------------------------------------------------------------------- /source/http.js: -------------------------------------------------------------------------------- 1 | var stream = require('stream'); 2 | var events = require('events'); 3 | var http = require('http'); 4 | var util = require('util'); 5 | 6 | var extend = require('extend'); 7 | 8 | var noop = function() {}; 9 | 10 | var REMOTE_ADDRESS = '127.0.0.1'; 11 | var LOCAL_ADDRESS = REMOTE_ADDRESS; 12 | var REMOTE_PORT = 9000; 13 | var LOCAL_PORT = REMOTE_PORT; 14 | var FAMILY = 'IPv4'; 15 | 16 | var length = function(data) { 17 | return (typeof data === 'string') ? Buffer.byteLength(data, 'utf-8') : data.length; 18 | }; 19 | 20 | var Output = function(parent) { 21 | stream.Writable.call(this); 22 | 23 | this._parent = parent; 24 | this._callback = noop; 25 | }; 26 | 27 | util.inherits(Output, stream.Writable); 28 | 29 | Output.prototype._write = function(data, encoding, callback) { 30 | if(this._parent.push(data, encoding)) { 31 | callback(); 32 | } else { 33 | this._callback = callback; 34 | } 35 | 36 | this._parent.bytesRead += length(data); 37 | }; 38 | 39 | var Input = function(parent) { 40 | stream.Readable.call(this); 41 | 42 | this._parent = parent; 43 | this._callback = noop; 44 | }; 45 | 46 | util.inherits(Input, stream.Readable); 47 | 48 | Input.prototype._read = function(size) { 49 | var callback = this._callback; 50 | this._callback = noop; 51 | callback(); 52 | }; 53 | 54 | var Socket = function(options) { 55 | stream.Duplex.call(this); 56 | 57 | options = options || {}; 58 | 59 | this.input = new Input(this); 60 | this.output = new Output(this); 61 | 62 | this.remoteAddress = options.remoteAddress || REMOTE_ADDRESS; 63 | this.remotePort = options.remotePort || REMOTE_PORT; 64 | 65 | this.localAddress = options.localAddress || LOCAL_ADDRESS; 66 | this.localPort = options.localPort || LOCAL_PORT; 67 | 68 | this.bytesRead = 0; 69 | this.bytesWritten = 0; 70 | 71 | this.bufferSize = 0; 72 | 73 | this._family = options.family || FAMILY; 74 | 75 | this._destroyed = false; 76 | 77 | var self = this; 78 | 79 | this.output.on('finish', function() { 80 | // End the readable part, writable output closed. 81 | self.push(null); 82 | }); 83 | this.on('finish', function() { 84 | // The writable part closed, end readable input. 85 | self.input.push(null); 86 | }); 87 | }; 88 | 89 | util.inherits(Socket, stream.Duplex); 90 | 91 | Socket.prototype._write = function(data, encoding, callback) { 92 | if(this.input.push(data)) { 93 | callback(); 94 | } else { 95 | this.input._callback = callback; 96 | } 97 | 98 | this.bytesWritten += length(data, encoding); 99 | }; 100 | 101 | Socket.prototype._read = function(size) { 102 | var callback = this.output._callback; 103 | this.output._callback = noop; 104 | callback(); 105 | }; 106 | 107 | Socket.prototype.connect = function() { 108 | this.emit('connect'); 109 | }; 110 | 111 | Socket.prototype.destroy = function() { 112 | if(this._destroyed) { 113 | return; 114 | } 115 | 116 | this._destroyed = true; 117 | this.emit('close'); 118 | }; 119 | 120 | Socket.prototype.address = function() { 121 | return { port: this.localPort, family: this._family, address: this.localAddress }; 122 | }; 123 | 124 | [ 125 | 'setTimeout', 126 | 'setNoDelay', 127 | 'setKeepAlive', 128 | 'unref', 129 | 'ref' 130 | ].forEach(function(name) { 131 | Socket.prototype[name] = noop; 132 | }); 133 | 134 | var createResponse = function(serverRequest) { 135 | var response = new http.ServerResponse(serverRequest); 136 | 137 | var socket = serverRequest.socket; 138 | var header; 139 | 140 | response.assignSocket(socket); 141 | 142 | // Internally used by node to buffer the whole header 143 | response.__defineSetter__('_header', function() { 144 | header = ' '; 145 | }); 146 | response.__defineGetter__('_header', function() { 147 | return header; 148 | }); 149 | 150 | // Stream data without chunks 151 | response.__defineSetter__('chunkedEncoding', noop); 152 | response.__defineGetter__('chunkedEncoding', function() { 153 | return false; 154 | }); 155 | 156 | socket.on('drain', function() { 157 | if(socket._httpMessage) { 158 | socket._httpMessage.emit('drain'); 159 | } 160 | }); 161 | response.on('finish', function() { 162 | socket.end(); 163 | }); 164 | 165 | return response; 166 | }; 167 | 168 | var createRequest = function(serverRequest, options) { 169 | options = options || {}; 170 | 171 | var request = new http.IncomingMessage(new Socket()); 172 | 173 | var headers = options.headers || serverRequest.headers; 174 | var trailers = options.trailers || serverRequest.trailers; 175 | 176 | [ 177 | 'httpVersion', 178 | 'httpVersionMajor', 179 | 'httpVersionMinor', 180 | 'url', 181 | 'method', 182 | 'complete', 183 | 'upgrade' 184 | ].forEach(function(name) { 185 | request[name] = options[name] || serverRequest[name]; 186 | }); 187 | 188 | extend(request.headers, headers); 189 | extend(request.trailers, trailers); 190 | 191 | return request; 192 | }; 193 | 194 | var create = function(serverRequest, options) { 195 | var request = createRequest(serverRequest, options); 196 | var response = createResponse(request); 197 | 198 | return { 199 | request: request, 200 | response: response 201 | }; 202 | }; 203 | 204 | module.exports = create; 205 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | var express = require('express'); 5 | var methodOverride = require('method-override'); 6 | var bodyParser = require('body-parser'); 7 | 8 | var users = require('./fixtures/users'); 9 | var albums = require('./fixtures/albums'); 10 | 11 | var root = function(name) { 12 | return path.join(__dirname, '..', name); 13 | }; 14 | 15 | var findByName = function(models, name) { 16 | return models.filter(function(m) { 17 | return m.name === name; 18 | })[0]; 19 | }; 20 | 21 | var findAllByOwner = function(models, owner) { 22 | return models.filter(function(m) { 23 | return m.owner === owner; 24 | }); 25 | }; 26 | 27 | var getBlob = function() { 28 | var blob = new Buffer(1024 * 1024); 29 | 30 | blob.fill('h'); 31 | blob.write('"', 0, 1); 32 | blob.write('"', blob.length - 1, 1); 33 | 34 | return blob; 35 | }; 36 | 37 | var json = function(request, response, next) { 38 | response.setHeader('Content-Type', 'application/json'); 39 | next(); 40 | }; 41 | 42 | var text = function(request, response, next) { 43 | response.setHeader('Content-Type', 'text/plain; charset=utf-8'); 44 | next(); 45 | }; 46 | 47 | var album = function(request, response, next) { 48 | var userAlbums = findAllByOwner(albums, request.params.user); 49 | var album = findByName(userAlbums, request.params.album); 50 | 51 | if(!album || album.owner !== request.params.user) { 52 | return response.notFound(); 53 | } 54 | 55 | request.album = album; 56 | next(); 57 | }; 58 | 59 | var create = function() { 60 | var app = express(); 61 | 62 | app.use(bodyParser.json()); 63 | app.use(methodOverride()); 64 | 65 | app.use(function(request, response, next) { 66 | response.notFound = function() { 67 | response.statusCode = 404; 68 | response.json({ message: 'Resource not found' }); 69 | }; 70 | 71 | next(); 72 | }); 73 | 74 | app.get('/', text, function(request, response) { 75 | response.send('Application "root"'); 76 | }); 77 | 78 | app.get('/README.md', text, function(request, response) { 79 | fs.createReadStream(root('README.md')).pipe(response); 80 | }); 81 | 82 | app.get('/package', function(request, response) { 83 | response.redirect('/api'); 84 | }); 85 | 86 | app.get('/cookie', function(request, response) { 87 | response.json({ cookie: request.headers.cookie }); 88 | }); 89 | 90 | app.get('/api', json, function(request, response) { 91 | fs.readFile(root('package.json'), function(err, data) { 92 | if(err) { 93 | return response.json({ message: err.message }); 94 | } 95 | 96 | response.setHeader('Content-Length', data.length); 97 | response.end(data); 98 | }); 99 | }); 100 | 101 | app.get('/api/users', json, function(request, response) { 102 | var skip = parseInt(request.query.skip, 10) || 0; 103 | var limit = parseInt(request.query.limit, 10) || users.length; 104 | 105 | response.json(users.slice(skip, skip + limit)); 106 | }); 107 | 108 | app.get('/api/users/:name', json, function(request, response) { 109 | var user = findByName(users, request.params.name); 110 | 111 | if(!user) { 112 | return response.notFound(); 113 | } 114 | 115 | response.json(user); 116 | }); 117 | 118 | app.post('/api/users', json, function(request, response) { 119 | var user = request.body; 120 | users.push(user); 121 | 122 | response.json(user); 123 | }); 124 | 125 | app.get('/api/users/:user/albums', json, function(request, response) { 126 | var user = findByName(users, request.params.user); 127 | var userAlbums = findAllByOwner(albums, user && user.name); 128 | 129 | if(!user) { 130 | return response.notFound(); 131 | } 132 | 133 | response.setHeader('Transfer-Encoding', 'chunked'); 134 | 135 | response.write('['); 136 | 137 | userAlbums.forEach(function(album, i) { 138 | response.write(JSON.stringify(album)); 139 | 140 | if(i < userAlbums.length - 1) { 141 | response.write(','); 142 | } 143 | }); 144 | 145 | response.write(']'); 146 | response.end(); 147 | }); 148 | 149 | app.get('/api/users/:user/albums/:album', json, album, function(request, response) { 150 | response.json(request.album); 151 | }); 152 | 153 | app.get('/api/users/:user/albums/:album/blob', json, album, function(request, response) { 154 | response.write(getBlob()); 155 | response.end(); 156 | }); 157 | 158 | app.get('/api/users/:user/albums/:album/stream', json, album, function(request, response) { 159 | var blob = getBlob(); 160 | var offset = 0; 161 | 162 | (function loop() { 163 | if(offset >= blob.length) { 164 | return response.end(); 165 | } 166 | 167 | var part = blob.slice(offset, offset + Math.pow(2, 17)); 168 | offset += part.length; 169 | 170 | if(response.write(part) !== false) { 171 | loop(); 172 | } else { 173 | response.once('drain', loop); 174 | } 175 | }()); 176 | }); 177 | 178 | return app; 179 | }; 180 | 181 | module.exports = create; 182 | 183 | if(require.main === module) { 184 | var PORT = 8080; 185 | var multifetch = require('../source/index'); 186 | 187 | create().get('/api/multifetch', multifetch()).listen(PORT, function() { 188 | console.log('Server listening on port ' + PORT); 189 | }); 190 | } 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | multifetch [![Build Status](https://travis-ci.org/debitoor/multifetch.png?branch=master)](https://travis-ci.org/debitoor/multifetch) 2 | ========== 3 | 4 | Express middleware for performing internal batch GET requests. It allows the client to send a single HTTP request, which in turn can fetch multiple JSON resources in the app, without performing any further requests. 5 | 6 | npm install multifetch 7 | 8 | Developed and tested with `node 0.10`. Versions above `1.0.0` of this module are tested with `express 4`, while previous versions used `express 3`. 9 | 10 | Usage 11 | ----- 12 | 13 | It can be used without any configuration. 14 | 15 | ```javascript 16 | var multifetch = require('multifetch'); 17 | var express = require('express'); 18 | 19 | var app = express(); 20 | 21 | app.get('/api/multifetch', multifetch()); 22 | 23 | app.get('/api/user', function(request, response) { 24 | response.json({ 25 | name: 'user_1', 26 | associates: ['user_2', 'user_3'] 27 | }); 28 | }); 29 | 30 | app.listen(8080); 31 | ``` 32 | 33 | Performing a GET request to `/api/multifetch?user=/api/user`, will return the user and some meta information. The query parameter should have a resource name as key and the relative path as value. The path can have its own query, as long it's encoded correctly. Furthermore the endpoint must return `application/json` or `text/json` (with or without character encoding) in the `content-type` header, or else the content will be ignored. 34 | 35 | ```javascript 36 | // Response JSON object 37 | { 38 | user: { 39 | statusCode: 200, // Response code returned by the user route 40 | headers: { // All response headers 41 | 'content-type': 'application/json', 42 | ... 43 | }, 44 | body: { // The actual json body 45 | name: 'user_1', 46 | associates: ['user_2', 'user_3'] 47 | } 48 | }, 49 | _error: false // _error will be true if one of the requests failed 50 | } 51 | ``` 52 | 53 | This way we can fetch multiple resources, by adding them to the query. If we had more routes defined, it would be possible to do. 54 | 55 | GET /api/multifetch?user=/api/user&albums=/api/users/user_1/albums&files=/api/files 56 | 57 | And the response will contain all the resources as described above. 58 | 59 | ```javascript 60 | { 61 | user: { 62 | statusCode: 200, 63 | headers: { ... }, 64 | body: { ... } 65 | }, 66 | albums: { 67 | statusCode: 200, 68 | headers: { ... }, 69 | body: [ ... ] 70 | }, 71 | files: { 72 | statusCode: 200, 73 | headers: { ... }, 74 | body: [ ... ] 75 | }, 76 | _error: false 77 | } 78 | ``` 79 | 80 | We don't perform any additional HTTP requests, instead express' internal routing is used to get the resources and send them back to client. The JSON is streamed to client one requests at the time. 81 | 82 | It is also possible to configure `multifetch` to ignore some of the query parameters, or call a provided callback function before performing any internal routing, which makes it possible to set any required headers on the internal request, e.g. api access tokens (the `cookie` header is set by default). 83 | 84 | ```javascript 85 | // Ignore access_token and token in the query 86 | app.get('/api/multifetch', multifetch({ ignore: ['access_token', 'token'] })); 87 | 88 | // Callback function run before each internal request. 89 | // The serverRequest argument, is the original request to multifetch, 90 | // while internalRequest is the fake request generated to get the actual resource. 91 | app.get('/api/multifetch', multifetch(function(serverRequset, internalRequest, next) { 92 | if(serverRequest.hasAccess) { 93 | // Calling next with a truthy value, skips this internal request. 94 | return next(true); 95 | } 96 | 97 | // Copy token 98 | internRequest.headers.token = serverRequest.headers.token || serverRequest.query.token; 99 | next(); 100 | })); 101 | ``` 102 | 103 | If `request.body` is available and is a JSON object, resources will also be included from there (body object with resource names as keys, and paths as values). This 104 | can de bone by using a `post` route with the `bodyParse` middleware. 105 | 106 | Requesting non JSON resources, where `content-type` doesn't contain `json`, returns `null` as body. 107 | 108 | Passing `headers: false` as an option, excludes `statusCode` and `headers` from the response, only the resource content is returned (the `_error` property is still available). 109 | 110 | ```javascript 111 | app.get('/api/multifetch', multifetch({ headers: false })); 112 | ``` 113 | 114 | Response with content only. 115 | 116 | ```javascript 117 | { 118 | user: { 119 | name: 'user_1', 120 | associates: ['user_2', 'user_3'] 121 | }, 122 | _error: false 123 | } 124 | ``` 125 | 126 | ### Concurrency 127 | By default, `multifetch` will process each request sequentially, waiting until a request has been processed and fully piped out before it processes the next request. 128 | 129 | If the response to each of your requests is small but takes a long time to fetch (e.g. heavy database queries), `multifetch` supports concurrently processing requests. 130 | 131 | Passing `concurrency: N` as an option allows you to control the number of concurrent requests being processed at any one time: 132 | 133 | ```javascript 134 | app.get('/api/multifetch', multifetch({ concurrency: 5 })); 135 | ``` 136 | In the above case, 5 requests would be routed through express concurrently, and the response of each is placed in a queue to be streamed out to the client sequentially. 137 | 138 | License 139 | ------- 140 | 141 | [MIT](http://opensource.org/licenses/MIT) 142 | -------------------------------------------------------------------------------- /test/integration/multifetch.options.spec.js: -------------------------------------------------------------------------------- 1 | var request = require('request'); 2 | var multifetch = helper.requireSource('index'); 3 | 4 | describe('multifetch.options', function() { 5 | var server, body; 6 | 7 | describe('ignore', function() { 8 | before(function(done) { 9 | server = helper.server(); 10 | 11 | server.get('/api/multifetch', multifetch({ ignore: ['access_token', 'token'] })); 12 | server = server.listen(helper.port, done); 13 | }); 14 | 15 | before(function(done) { 16 | request.get({ 17 | url: helper.url('/api/multifetch'), 18 | qs: { user: '/api/users/user_1', token: 'my_token' }, 19 | json: true 20 | }, function(err, _, result) { 21 | body = result; 22 | done(err); 23 | }); 24 | }); 25 | 26 | after(function(done) { 27 | server.close(done); 28 | }); 29 | 30 | it('should be successful response', function() { 31 | chai.expect(body).to.have.property('_error', false); 32 | }); 33 | 34 | it('should ignore token query parameter', function() { 35 | chai.expect(body).not.to.have.property('token'); 36 | }); 37 | 38 | it('should contain user', function() { 39 | chai.expect(body) 40 | .to.have.property('user') 41 | .to.have.property('statusCode', 200); 42 | }); 43 | }); 44 | 45 | describe('callback', function() { 46 | before(function(done) { 47 | var callback = function(serverRequest, internalRequest, next) { 48 | if(internalRequest.url === '/api/users') { 49 | return next(true); 50 | } 51 | 52 | next(); 53 | }; 54 | 55 | server = helper.server(); 56 | 57 | server.get('/api/multifetch', multifetch(callback)); 58 | server = server.listen(helper.port, done); 59 | }); 60 | 61 | before(function(done) { 62 | request.get({ 63 | url: helper.url('/api/multifetch'), 64 | qs: { 65 | album: '/api/users/user_1/albums/album_1', 66 | users: '/api/users' 67 | }, 68 | json: true 69 | }, function(err, _, result) { 70 | body = result; 71 | done(err); 72 | }); 73 | }); 74 | 75 | after(function(done) { 76 | server.close(done); 77 | }); 78 | 79 | it('should be successful response', function() { 80 | chai.expect(body).to.have.property('_error', false); 81 | }); 82 | 83 | it('should not have users', function() { 84 | chai.expect(body).not.to.have.property('users'); 85 | }); 86 | 87 | it('should contain album', function() { 88 | chai.expect(body) 89 | .to.have.property('album') 90 | .to.have.property('statusCode', 200); 91 | }); 92 | }); 93 | 94 | describe('headers', function() { 95 | before(function(done) { 96 | server = helper.server(); 97 | 98 | server.get('/api/multifetch', multifetch({ headers: false })); 99 | server = server.listen(helper.port, done); 100 | }); 101 | 102 | after(function(done) { 103 | server.close(done); 104 | }); 105 | 106 | describe('fetch multiple resources', function() { 107 | before(function(done) { 108 | request.get({ 109 | url: helper.url('/api/multifetch'), 110 | qs: { 111 | user: '/api/users/user_1', 112 | album: '/api/users/user_1/albums/album_1' 113 | }, 114 | json: true 115 | }, function(err, _, result) { 116 | body = result; 117 | done(err); 118 | }); 119 | }); 120 | 121 | it('should be successful response', function() { 122 | chai.expect(body).to.have.property('_error', false); 123 | }); 124 | 125 | it('should contain user_1', function() { 126 | chai.expect(body) 127 | .to.have.property('user') 128 | .to.deep.equal({ 129 | name: 'user_1', 130 | associates: [], 131 | location: { 132 | city: 'Copenhagen', 133 | address: 'Wildersgade' 134 | } 135 | }); 136 | }); 137 | 138 | it('should contain album_1', function() { 139 | chai.expect(body) 140 | .to.have.property('album') 141 | .to.deep.equal({ 142 | owner: 'user_1', 143 | name: 'album_1', 144 | date: '2013-12-12', 145 | files: [{ 146 | name: 'file_1', 147 | size: 128 148 | }, { 149 | name: 'file_2', 150 | size: 512 151 | }] 152 | }); 153 | }); 154 | }); 155 | 156 | describe('hang up on bad request', function() { 157 | var err; 158 | 159 | before(function(done) { 160 | request.get({ 161 | url: helper.url('/api/multifetch'), 162 | qs: { user: '/api/not_found' }, 163 | json: true 164 | }, function(result) { 165 | err = result; 166 | done(); 167 | }); 168 | }); 169 | 170 | it('should emit error', function() { 171 | chai.expect(err).to.defined; 172 | }); 173 | }); 174 | 175 | describe('get non json resource', function() { 176 | before(function(done) { 177 | request.get({ 178 | url: helper.url('/api/multifetch'), 179 | qs: { root: '/' }, 180 | json: true 181 | }, function(err, _, result) { 182 | body = result; 183 | done(err); 184 | }); 185 | }); 186 | 187 | it('should be failed response', function() { 188 | chai.expect(body).to.have.property('_error', false); 189 | }); 190 | 191 | it('should have string as body', function() { 192 | chai.expect(body) 193 | .to.have.property('root', null); 194 | }); 195 | }); 196 | }); 197 | 198 | describe('post json', function() { 199 | before(function(done) { 200 | server = helper.server(); 201 | 202 | server.post('/api/multifetch', multifetch()); 203 | server = server.listen(helper.port, done); 204 | }); 205 | 206 | before(function(done) { 207 | request.post({ 208 | url: helper.url('/api/multifetch'), 209 | body: { user: '/api/users/user_1' }, 210 | json: true 211 | }, function(err, _, result) { 212 | body = result; 213 | done(err); 214 | }); 215 | }); 216 | 217 | after(function(done) { 218 | server.close(done); 219 | }); 220 | 221 | it('should be successful response', function() { 222 | chai.expect(body).to.have.property('_error', false); 223 | }); 224 | 225 | it('should contain user_1', function() { 226 | chai.expect(body) 227 | .to.have.property('user') 228 | .to.contain.subset({ 229 | statusCode: 200, 230 | body: { 231 | name: 'user_1', 232 | associates: [], 233 | location: { 234 | city: 'Copenhagen', 235 | address: 'Wildersgade' 236 | } 237 | } 238 | }); 239 | }); 240 | }); 241 | 242 | describe('concurrent fetching', function() { 243 | before(function(done) { 244 | server = helper.server(); 245 | 246 | server.get('/api/multifetch', multifetch({ concurrency: 5 })); 247 | server = server.listen(helper.port, done); 248 | }); 249 | 250 | after(function(done) { 251 | server.close(done); 252 | }); 253 | 254 | describe('fetch multiple resources', function() { 255 | before(function(done) { 256 | request.get({ 257 | url: helper.url('/api/multifetch'), 258 | qs: { 259 | api: '/api', 260 | user_1: '/api/users/user_1', 261 | user_2: '/api/users/user_2', 262 | user_3: '/api/users/user_3', 263 | readme: '/README.md' 264 | }, 265 | json: true 266 | }, function(err, _, result) { 267 | body = result; 268 | done(err); 269 | }); 270 | }); 271 | 272 | it('should be successful response', function() { 273 | chai.expect(body).to.have.property('_error', false); 274 | }); 275 | 276 | it('should fetch api resource', function() { 277 | chai.expect(body) 278 | .to.have.property('api') 279 | .to.have.property('statusCode', 200); 280 | }); 281 | 282 | it('should fetch user_1 resource', function() { 283 | chai.expect(body) 284 | .to.have.property('user_1') 285 | .to.have.property('statusCode', 200); 286 | }); 287 | 288 | it('should fetch user_2 resource', function() { 289 | chai.expect(body) 290 | .to.have.property('user_2') 291 | .to.have.property('statusCode', 200); 292 | }); 293 | 294 | it('should fetch user_3 resource', function() { 295 | chai.expect(body) 296 | .to.have.property('user_3') 297 | .to.have.property('statusCode', 200); 298 | }); 299 | 300 | it('should fetch user_4 resource', function() { 301 | chai.expect(body) 302 | .to.have.property('readme') 303 | .to.have.property('statusCode', 200); 304 | }); 305 | }); 306 | 307 | describe('hang up on bad request', function() { 308 | var err; 309 | 310 | before(function(done) { 311 | request.get({ 312 | url: helper.url('/api/multifetch'), 313 | qs: { 314 | bad: '/api/not_found', 315 | }, 316 | json: true 317 | }, function(result) { 318 | err = result; 319 | done(); 320 | }); 321 | }); 322 | 323 | it('should emit an error', function() { 324 | chai.expect(err).to.be.defined; 325 | }); 326 | }); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /test/integration/multifetch.spec.js: -------------------------------------------------------------------------------- 1 | var request = require('request'); 2 | var multifetch = helper.requireSource('index'); 3 | 4 | describe('multifetch', function() { 5 | var server, body; 6 | 7 | before(function(done) { 8 | server = helper.server(); 9 | 10 | server.get('/api/multifetch', multifetch()); 11 | server = server.listen(helper.port, done); 12 | }); 13 | 14 | after(function(done) { 15 | server.close(done); 16 | }); 17 | 18 | describe('empty request', function() { 19 | before(function(done) { 20 | request.get({ 21 | url: helper.url('/api/multifetch'), 22 | qs: {}, 23 | json: true 24 | }, function(err, _, result) { 25 | body = result; 26 | done(err); 27 | }); 28 | }); 29 | 30 | it('should be an empty response', function() { 31 | chai.expect(body).to.deep.equal({ _error: false }); 32 | }); 33 | }); 34 | 35 | describe('get single user', function() { 36 | before(function(done) { 37 | request.get({ 38 | url: helper.url('/api/multifetch'), 39 | qs: { user: '/api/users/user_1' }, 40 | json: true 41 | }, function(err, _, result) { 42 | body = result; 43 | done(err); 44 | }); 45 | }); 46 | 47 | it('should be successful response', function() { 48 | chai.expect(body).to.have.property('_error', false); 49 | }); 50 | 51 | it('should contain headers', function() { 52 | chai.expect(body) 53 | .to.have.property('user') 54 | .to.have.property('headers') 55 | .to.have.property('content-type') 56 | .to.match(/^application\/json/); 57 | }); 58 | 59 | it('should contain user_1', function() { 60 | chai.expect(body) 61 | .to.have.property('user') 62 | .to.contain.subset({ 63 | statusCode: 200, 64 | body: { 65 | name: 'user_1', 66 | associates: [], 67 | location: { 68 | city: 'Copenhagen', 69 | address: 'Wildersgade' 70 | } 71 | } 72 | }); 73 | }); 74 | }); 75 | 76 | describe('get all users', function() { 77 | before(function(done) { 78 | request.get({ 79 | url: helper.url('/api/multifetch'), 80 | qs: { users: '/api/users' }, 81 | json: true 82 | }, function(err, _, result) { 83 | body = result; 84 | done(err); 85 | }); 86 | }); 87 | 88 | it('should be successful response', function() { 89 | chai.expect(body).to.have.property('_error', false); 90 | }); 91 | 92 | it('should contain users with status ok', function() { 93 | chai.expect(body) 94 | .to.have.property('users') 95 | .to.have.property('statusCode', 200); 96 | }); 97 | 98 | it('should contain users array', function() { 99 | chai.expect(body) 100 | .to.have.property('users') 101 | .to.have.property('body') 102 | .to.be.instanceof(Array); 103 | }); 104 | 105 | it('should contain all users in array', function() { 106 | chai.expect(body.users.body.length).to.equal(3); 107 | }); 108 | }); 109 | 110 | describe('get chunked albums for user_3', function() { 111 | before(function(done) { 112 | request.get({ 113 | url: helper.url('/api/multifetch'), 114 | qs: { albums: '/api/users/user_3/albums' }, 115 | json: true 116 | }, function(err, _, result) { 117 | body = result; 118 | done(err); 119 | }); 120 | }); 121 | 122 | it('should be successful response', function() { 123 | chai.expect(body).to.have.property('_error', false); 124 | }); 125 | 126 | it('should contain albums with status ok', function() { 127 | chai.expect(body) 128 | .to.have.property('albums') 129 | .to.have.property('statusCode', 200); 130 | }); 131 | 132 | it('should contain albums array', function() { 133 | chai.expect(body) 134 | .to.have.property('albums') 135 | .to.have.property('body') 136 | .to.be.instanceof(Array); 137 | }); 138 | 139 | it('should contain all albums for user', function() { 140 | chai.expect(body.albums.body.length).to.equal(5); 141 | }); 142 | }); 143 | 144 | describe('get multiple resources', function() { 145 | before(function(done) { 146 | request.get({ 147 | url: helper.url('/api/multifetch'), 148 | qs: { 149 | albums: '/api/users/user_3/albums', 150 | user: '/api/users/user_2', 151 | album: '/api/users/user_2/albums/album_1' 152 | }, 153 | json: true 154 | }, function(err, _, result) { 155 | body = result; 156 | done(err); 157 | }); 158 | }); 159 | 160 | it('should be successful response', function() { 161 | chai.expect(body).to.have.property('_error', false); 162 | }); 163 | 164 | it('should contain albums with status ok', function() { 165 | chai.expect(body) 166 | .to.have.property('albums') 167 | .to.have.property('statusCode', 200); 168 | }); 169 | 170 | it('should contain albums array', function() { 171 | chai.expect(body) 172 | .to.have.property('albums') 173 | .to.have.property('body') 174 | .to.be.instanceof(Array); 175 | }); 176 | 177 | it('should contain all albums for user', function() { 178 | chai.expect(body.albums.body.length).to.equal(5); 179 | }); 180 | 181 | it('should contain user', function() { 182 | chai.expect(body) 183 | .to.have.property('user') 184 | .to.contain.subset({ 185 | statusCode: 200, 186 | body: { 187 | name: 'user_2', 188 | associates: ['user_1', 'user_3'], 189 | location: { 190 | city: 'Aarhus', 191 | address: 'Niels Borhs Vej' 192 | } 193 | } 194 | }); 195 | }); 196 | 197 | it('should contain album for user', function() { 198 | chai.expect(body) 199 | .to.have.property('album') 200 | .to.contain.subset({ 201 | statusCode: 200, 202 | body: { 203 | owner: 'user_2', 204 | name: 'album_1', 205 | date: '2013-12-01', 206 | files: [{ 207 | name: 'file_1', 208 | size: 512 209 | }] 210 | } 211 | }); 212 | }); 213 | }); 214 | 215 | describe('error on invalid resource', function() { 216 | before(function(done) { 217 | request.get({ 218 | url: helper.url('/api/multifetch'), 219 | qs: { user: '/api/users/not_valid' }, 220 | json: true 221 | }, function(err, _, result) { 222 | body = result; 223 | done(err); 224 | }); 225 | }); 226 | 227 | it('should be failed response', function() { 228 | chai.expect(body).to.have.property('_error', true); 229 | }); 230 | 231 | it('should contain user with status not found', function() { 232 | chai.expect(body) 233 | .to.have.property('user') 234 | .to.have.property('statusCode', 404); 235 | }); 236 | 237 | it('should contain user error', function() { 238 | chai.expect(body) 239 | .to.have.property('user') 240 | .to.have.property('body') 241 | .to.have.property('message'); 242 | }); 243 | }); 244 | 245 | describe('hang up on bad request', function() { 246 | var err; 247 | 248 | before(function(done) { 249 | request.get({ 250 | url: helper.url('/api/multifetch'), 251 | qs: { user: '/api/not_found' }, 252 | json: true 253 | }, function(result) { 254 | err = result; 255 | done(); 256 | }); 257 | }); 258 | 259 | it('should emit error', function() { 260 | chai.expect(err).to.defined; 261 | }); 262 | }); 263 | 264 | describe('get users with query', function() { 265 | before(function(done) { 266 | request.get({ 267 | url: helper.url('/api/multifetch'), 268 | qs: { user: '/api/users?skip=1&limit=1' }, 269 | json: true 270 | }, function(err, _, result) { 271 | body = result; 272 | done(err); 273 | }); 274 | }); 275 | 276 | it('should be successful response', function() { 277 | chai.expect(body).to.have.property('_error', false); 278 | }); 279 | 280 | it('should contain single user in array', function() { 281 | chai.expect(body) 282 | .to.have.property('user') 283 | .to.contain.subset({ 284 | statusCode: 200, 285 | body: [ 286 | { 287 | name: 'user_2', 288 | associates: ['user_1', 'user_3'], 289 | location: { 290 | city: 'Aarhus', 291 | address: 'Niels Borhs Vej' 292 | } 293 | } 294 | ] 295 | }); 296 | }); 297 | }); 298 | 299 | describe('get multifetch in query', function() { 300 | before(function(done) { 301 | request.get({ 302 | url: helper.url('/api/multifetch'), 303 | qs: { 304 | multifetch: '/api/multifetch', 305 | album: '/api/users/user_3/albums/album_3' 306 | }, 307 | json: true 308 | }, function(err, _, result) { 309 | body = result; 310 | done(err); 311 | }); 312 | }); 313 | 314 | it('should be a successful response', function() { 315 | chai.expect(body).to.have.property('_error', false); 316 | }); 317 | 318 | it('should ignore multifetch resource', function() { 319 | chai.expect(body).not.to.have.property('multifetch'); 320 | }); 321 | 322 | it('should contain user album', function() { 323 | chai.expect(body) 324 | .to.have.property('album') 325 | .to.contain.subset({ 326 | statusCode: 200, 327 | body: { 328 | owner: 'user_3', 329 | name: 'album_3', 330 | date: '2013-11-29', 331 | files: [{ 332 | name: 'file_2', 333 | size: 1024 334 | }] 335 | } 336 | }); 337 | }); 338 | }); 339 | 340 | describe('get api data (async)', function() { 341 | before(function(done) { 342 | request.get({ 343 | url: helper.url('/api/multifetch'), 344 | qs: { 345 | api: '/api', 346 | user: '/api/users/user_3' 347 | }, 348 | json: true 349 | }, function(err, _, result) { 350 | body = result; 351 | done(err); 352 | }); 353 | }); 354 | 355 | it('should be a successful response', function() { 356 | chai.expect(body).to.have.property('_error', false); 357 | }); 358 | 359 | it('should contain user', function() { 360 | chai.expect(body) 361 | .to.have.property('user') 362 | .to.have.property('statusCode', 200); 363 | }); 364 | 365 | it('should contain api data', function() { 366 | chai.expect(body) 367 | .to.have.property('api') 368 | .to.have.property('statusCode', 200); 369 | }); 370 | }); 371 | 372 | describe('get proxied cookie', function() { 373 | before(function(done) { 374 | request.get({ 375 | url: helper.url('/api/multifetch'), 376 | qs: { cookie: '/cookie' }, 377 | headers: { cookie: 'my_test_cookie' }, 378 | json: true 379 | }, function(err, _, result) { 380 | body = result; 381 | done(err); 382 | }); 383 | }); 384 | 385 | it('should be successful response', function() { 386 | chai.expect(body).to.have.property('_error', false); 387 | }); 388 | 389 | it('should contain proxied cookie', function() { 390 | chai.expect(body) 391 | .to.have.deep.property('cookie.body') 392 | .to.eql({ cookie: 'my_test_cookie' }); 393 | }); 394 | }); 395 | 396 | describe('get non json resource', function() { 397 | before(function(done) { 398 | request.get({ 399 | url: helper.url('/api/multifetch'), 400 | qs: { root: '/' }, 401 | json: true 402 | }, function(err, _, result) { 403 | body = result; 404 | done(err); 405 | }); 406 | }); 407 | 408 | it('should be failed response', function() { 409 | chai.expect(body).to.have.property('_error', false); 410 | }); 411 | 412 | it('should have string as body', function() { 413 | chai.expect(body) 414 | .to.have.property('root') 415 | .to.have.property('body', null); 416 | }); 417 | }); 418 | 419 | describe('get redirect', function() { 420 | before(function(done) { 421 | request.get({ 422 | url: helper.url('/api/multifetch'), 423 | qs: { api: '/package' }, 424 | json: true 425 | }, function(err, _, result) { 426 | body = result; 427 | done(err); 428 | }); 429 | }); 430 | 431 | it('should be failed response', function() { 432 | chai.expect(body).to.have.property('_error', true); 433 | }); 434 | 435 | it('should have api with status found', function() { 436 | chai.expect(body) 437 | .to.have.property('api') 438 | .to.have.property('statusCode', 302); 439 | }); 440 | }); 441 | 442 | describe('get big data', function() { 443 | before(function(done) { 444 | request.get({ 445 | url: helper.url('/api/multifetch'), 446 | qs: { blob: '/api/users/user_3/albums/album_3/blob' }, 447 | json: true 448 | }, function(err, _, result) { 449 | body = result; 450 | done(err); 451 | }); 452 | }); 453 | 454 | it('should be successful response', function() { 455 | chai.expect(body).to.have.property('_error', false); 456 | }); 457 | 458 | it('should contain blob with status ok', function() { 459 | chai.expect(body) 460 | .to.have.property('blob') 461 | .to.have.property('statusCode', 200); 462 | }); 463 | 464 | it('should contain blob of type string', function() { 465 | chai.expect(body) 466 | .to.have.property('blob') 467 | .to.have.property('body') 468 | .to.be.a('string'); 469 | }); 470 | 471 | it('should contain blob of 1MB size', function() { 472 | // Subtract two (2) bytes representing the quotes 473 | chai.expect(body.blob.body.length).to.equal(1024 * 1024 - 2); 474 | }); 475 | }); 476 | 477 | describe('stream big data', function() { 478 | before(function(done) { 479 | request.get({ 480 | url: helper.url('/api/multifetch'), 481 | qs: { blob: '/api/users/user_3/albums/album_3/stream' }, 482 | json: true 483 | }, function(err, _, result) { 484 | body = result; 485 | done(err); 486 | }); 487 | }); 488 | 489 | it('should be successful response', function() { 490 | chai.expect(body).to.have.property('_error', false); 491 | }); 492 | 493 | it('should contain blob with status ok', function() { 494 | chai.expect(body) 495 | .to.have.property('blob') 496 | .to.have.property('statusCode', 200); 497 | }); 498 | 499 | it('should contain blob of type string', function() { 500 | chai.expect(body) 501 | .to.have.property('blob') 502 | .to.have.property('body') 503 | .to.be.a('string'); 504 | }); 505 | 506 | it('should contain blob of 1MB size', function() { 507 | // Subtract two (2) bytes representing the quotes 508 | chai.expect(body.blob.body.length).to.equal(1024 * 1024 - 2); 509 | }); 510 | }); 511 | }); 512 | --------------------------------------------------------------------------------