├── .coveralls.yml ├── .gitignore ├── examples └── simple │ ├── .gitignore │ ├── shared │ ├── config.js │ ├── index.html │ └── getFlickrPhotos.js │ ├── webpack.config.js │ ├── README.md │ ├── package.json │ ├── client │ └── app.js │ └── server │ ├── fetchers │ └── flickr.js │ └── app.js ├── .npmignore ├── index.js ├── .travis.yml ├── CONTRIBUTING.md ├── .editorconfig ├── tests ├── mock │ ├── corsApp.js │ ├── app.js │ ├── MockErrorService.js │ └── MockService.js └── unit │ └── libs │ ├── util │ └── http.client.js │ ├── fetcher.client.js │ └── fetcher.js ├── package.json ├── LICENSE.md ├── libs ├── util │ ├── defaultConstructGetUri.js │ └── http.client.js ├── fetcher.client.js └── fetcher.js ├── docs └── fetchr.md └── README.md /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /artifacts/ 2 | node_modules 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /examples/simple/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | client/build/ 3 | -------------------------------------------------------------------------------- /examples/simple/shared/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | xhrPath: '/myapi' 3 | }; 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.yml 2 | .editorconfig 3 | /docs/ 4 | /artifacts/ 5 | /examples/ 6 | /tests/ 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | module.exports = require('./libs/fetcher'); 6 | -------------------------------------------------------------------------------- /examples/simple/shared/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fetchr Example 5 | 6 | 7 |

8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | matrix: 4 | allow_failures: 5 | - node_js: "0.13" 6 | node_js: 7 | - "iojs" 8 | - "0.13" 9 | - "0.12" 10 | - "0.10" 11 | after_success: 12 | - "npm run cover" 13 | - "cat artifacts/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing Code to `fetchr` 2 | ------------------------------- 3 | 4 | Please be sure to sign our [CLA][] before you submit pull requests or otherwise contribute to `fetchr`. This protects developers, who rely on [BSD license][]. 5 | 6 | [BSD license]: https://github.com/yahoo/fetchr/blob/master/LICENSE.md 7 | [CLA]: https://yahoocla.herokuapp.com/ 8 | -------------------------------------------------------------------------------- /examples/simple/webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | module.exports = { 6 | entry: require.resolve('./client/app.js'), 7 | output: { 8 | path: __dirname+'/client/build/', 9 | filename: "app.js" 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | #Simple Fetcher Example 2 | 3 | ##Start server 4 | 5 | ### Install 6 | 7 | Start fetchr root: 8 | 9 | ```js 10 | npm install 11 | cd examples/simple 12 | npm install 13 | ``` 14 | 15 | ### Start server 16 | 17 | ```js 18 | npm run start 19 | ``` 20 | 21 | ##See server side output 22 | 23 | Visit `localhost:3000/server` in your browser 24 | 25 | ##See client side output 26 | 27 | Visit `localhost:3000/client` in your browser 28 | -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetchr-simple-example", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "server/app.js", 6 | "author": "Rajiv Tirumalareddy", 7 | "scripts": { 8 | "start": "webpack && node server/app.js" 9 | }, 10 | "dependencies": { 11 | "body-parser": "^1.10.0", 12 | "express": "^4.4.3", 13 | "superagent": "^0.18.0" 14 | }, 15 | "devDependencies": { 16 | "webpack": "^1.3.0-beta8" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 4 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [{package.json,.travis.yml}] 23 | indent_style = space 24 | indent_size = 2 25 | -------------------------------------------------------------------------------- /examples/simple/shared/getFlickrPhotos.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | module.exports = function flickrRead (fetcher, callback) { 6 | fetcher 7 | .read('flickr') 8 | .params({ 9 | method: 'flickr.photos.getRecent', 10 | per_page: 5 11 | }) 12 | .end(function(err, data) { 13 | if (err) { 14 | callback && callback(new Error('failed to fetch data ' + err.message)); 15 | } 16 | callback && callback(null, data); 17 | }); 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /tests/mock/corsApp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 6 | var http = require('http'); 7 | var express = require('express'); 8 | var bodyParser = require('body-parser'); 9 | 10 | var app = express(); 11 | 12 | function dummyHandler (req, res) { 13 | if (req.query.corsDomain === 'test1' && req.get('origin')) { 14 | res.set('Access-Control-Allow-Origin', req.get('origin')); 15 | } 16 | res.status(200).json(req.query); 17 | } 18 | 19 | app.use(bodyParser.json()); 20 | app.get('/mock_service', dummyHandler); 21 | app.post('/', dummyHandler); 22 | 23 | var port = 3001; 24 | var server = http.createServer(app).listen(port); 25 | console.log('Listening on port ' + port); 26 | module.exports = server; 27 | -------------------------------------------------------------------------------- /tests/mock/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 6 | var http = require('http'); 7 | var express = require('express'); 8 | var bodyParser = require('body-parser'); 9 | var Fetcher = require('../../libs/fetcher.js'); 10 | var mockService = require('./MockService'); 11 | var mockErrorService = require('./MockErrorService'); 12 | 13 | Fetcher.registerService(mockService); 14 | Fetcher.registerService(mockErrorService); 15 | 16 | var app = express(); 17 | app.use(bodyParser.json()); 18 | app.use('/api', Fetcher.middleware()); 19 | var port = process.env.PORT || 3000; 20 | var server = http.createServer(app).listen(port); 21 | console.log('Listening on port ' + port); 22 | module.exports = server; 23 | module.exports.cleanup = function () { 24 | Fetcher.services = {}; 25 | }; 26 | -------------------------------------------------------------------------------- /examples/simple/client/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | var readFlickr = require('../shared/getFlickrPhotos'), 6 | readFlickrClient, 7 | config = require('../shared/config'), 8 | Fetcher = require('../../../libs/fetcher.client.js'), 9 | fetcher = new Fetcher({ 10 | xhrPath: config.xhrPath, 11 | requireCrumb: false 12 | }); 13 | 14 | //client specific callback 15 | readFlickrClient = function(err, data) { 16 | if(err) { 17 | throw err; 18 | } 19 | //client specific logic 20 | var dataContainer = document.getElementById('flickr-data'), 21 | h1 = document.getElementById('env'); 22 | // set the environment h1 23 | h1.innerHTML = 'Client'; 24 | // output the data 25 | dataContainer.innerHTML = JSON.stringify(data); 26 | }; 27 | 28 | //client-server agnostic call for data 29 | readFlickr(fetcher, readFlickrClient); 30 | -------------------------------------------------------------------------------- /examples/simple/server/fetchers/flickr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | var request = require('superagent'), 6 | flickr_api_key = '7debd4efa50e751b97e1b34b78c14231', 7 | flickr_api_root = 'https://api.flickr.com/services/rest/', 8 | querystring = require('querystring'), 9 | FlickrFetcher; 10 | 11 | FlickrFetcher = { 12 | name: 'flickr', 13 | //At least one of the CRUD methods is Required 14 | read: function(req, resource, params, config, callback) { 15 | var paramsObj = { 16 | api_key: flickr_api_key, 17 | method: params.method || 'flickr.photos.getRecent', 18 | per_page: parseInt(params.per_page, 10) || 10, 19 | format: 'json', 20 | nojsoncallback: config.nojsoncallback || 1 21 | }, 22 | url = flickr_api_root + '?' + querystring.stringify(paramsObj); 23 | 24 | request 25 | .get(url) 26 | .end(function(err, res) { 27 | callback(err, JSON.parse(res.text)); 28 | }); 29 | } 30 | //TODO: other methods 31 | //create: function(req, resource, params, body, config, callback) {}, 32 | //update: function(req, resource, params, body, config, callback) {}, 33 | //delete: function(req, resource, params, config, callback) {} 34 | 35 | }; 36 | 37 | module.exports = FlickrFetcher; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetchr", 3 | "version": "0.5.25", 4 | "description": "Fetchr augments Flux applications by allowing Flux stores to be used on server and client to fetch data", 5 | "main": "index.js", 6 | "browser": "./libs/fetcher.client.js", 7 | "scripts": { 8 | "cover": "istanbul cover --dir artifacts -- ./node_modules/mocha/bin/_mocha tests/unit/ --recursive --reporter spec", 9 | "lint": "jshint libs tests", 10 | "test": "mocha tests/unit/ --recursive --reporter spec" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git@github.com:yahoo/fetchr" 15 | }, 16 | "author": "Rajiv Tirumalareddy ", 17 | "licenses": [ 18 | { 19 | "type": "BSD", 20 | "url": "https://github.com/yahoo/fetchr/blob/master/LICENSE.md" 21 | } 22 | ], 23 | "dependencies": { 24 | "debug": "^2.0.0", 25 | "es6-promise": "^3.0.2", 26 | "fumble": "^0.1.0", 27 | "lodash": "^4.0.1", 28 | "object-assign": "^4.0.1", 29 | "xhr": "^2.0.0" 30 | }, 31 | "devDependencies": { 32 | "body-parser": "^1.12.2", 33 | "chai": "^3.0.0", 34 | "coveralls": "^2.11.1", 35 | "express": "^4.12.3", 36 | "istanbul": "^0.3.2", 37 | "jshint": "^2.5.1", 38 | "mocha": "^2.0.1", 39 | "mockery": "^1.4.0", 40 | "pre-commit": "^1.0.0", 41 | "qs": "^4.0.0", 42 | "request": "^2.61.0", 43 | "supertest": "^1.0.1" 44 | }, 45 | "jshintConfig": { 46 | "node": true 47 | }, 48 | "keywords": [ 49 | "yahoo", 50 | "flux", 51 | "react", 52 | "fetchr", 53 | "dispatchr" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Software License Agreement (BSD License) 2 | ======================================== 3 | 4 | Copyright (c) 2014, Yahoo! Inc. All rights reserved. 5 | ---------------------------------------------------- 6 | 7 | Redistribution and use of this software in source and binary forms, with or 8 | without modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | * Neither the name of Yahoo! Inc. nor the names of YUI's contributors may be 17 | used to endorse or promote products derived from this software without 18 | specific prior written permission of Yahoo! Inc. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /examples/simple/server/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | var http = require('http'), 6 | path = require('path'), 7 | fs = require('fs'), 8 | express = require('express'), 9 | bodyParser = require('body-parser'), 10 | config = require('../shared/config'), 11 | Fetcher = require('../../../libs/fetcher.js'), 12 | readFlickr = require('../shared/getFlickrPhotos'), 13 | flickrFetcher = require('./fetchers/flickr'), 14 | readFlickrServer, 15 | templatePath = path.join(__dirname, '..', 'shared', 'index.html'); 16 | 17 | Fetcher.registerFetcher(flickrFetcher); 18 | 19 | var app = express(); 20 | 21 | app.use(bodyParser.json()); 22 | 23 | app.use(config.xhrPath, Fetcher.middleware()); 24 | 25 | 26 | app.use('/server', function (req, res, next) { 27 | 28 | var fetcher = new Fetcher({req: req}); 29 | 30 | //client specific callback 31 | readFlickrServer = function(err, data) { 32 | if(err) { 33 | throw err; 34 | } 35 | 36 | //server specific logic 37 | var tpl = fs.readFileSync(templatePath, {encoding: 'utf8'}), 38 | output = JSON.stringify(data); 39 | 40 | 41 | // set the environment h1 42 | tpl = tpl.replace('

', '

Server

'); 43 | // remove script tag 44 | tpl = tpl.replace('', ''); 45 | // output the data 46 | tpl = tpl.replace('
', output); 47 | 48 | res.send(tpl); 49 | }; 50 | 51 | //client-server agnostic call for data 52 | readFlickr(fetcher, readFlickrServer); 53 | 54 | }); 55 | 56 | //For the webpack built app.js that is needed by the index.html client file 57 | app.use(express.static(path.join(__dirname, '..', 'client', 'build'))); 58 | 59 | //For the index.html file 60 | app.use('/client', function(req, res) { 61 | var tpl = fs.readFileSync(templatePath, {encoding: 'utf8'}); 62 | res.send(tpl); 63 | }); 64 | 65 | var port = process.env.PORT || 3000; 66 | http.createServer(app).listen(port); 67 | console.log('Listening on port ' + port); 68 | -------------------------------------------------------------------------------- /libs/util/defaultConstructGetUri.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 'use strict'; 6 | 7 | var debug = require('debug')('Fetcher:defaultConstructGetUri'); 8 | var lodash = { 9 | forEach: require('lodash/forEach'), 10 | isArray: require('lodash/isArray'), 11 | isObject: require('lodash/isObject') 12 | }; 13 | 14 | function jsonifyComplexType(value) { 15 | if (lodash.isArray(value) || lodash.isObject(value)) { 16 | return JSON.stringify(value); 17 | } 18 | return value; 19 | } 20 | 21 | /** 22 | * Construct xhr GET URI. 23 | * @method defaultConstructGetUri 24 | * @param {String} uri base URI 25 | * @param {String} resource Resource name 26 | * @param {Object} params Parameters to be serialized 27 | * @param {Object} config Configuration object 28 | * @param {String} config.id_param Name of the id parameter 29 | * @param {Object} context Context object, which will become query params 30 | */ 31 | module.exports = function defaultConstructGetUri(baseUri, resource, params, config, context) { 32 | var query = []; 33 | var matrix = []; 34 | var id_param = config.id_param; 35 | var id_val; 36 | var final_uri = baseUri + '/' + resource; 37 | 38 | if (params) { 39 | lodash.forEach(params, function eachParam(v, k) { 40 | if (k === id_param) { 41 | id_val = encodeURIComponent(v); 42 | } else if (v !== undefined) { 43 | try { 44 | matrix.push(k + '=' + encodeURIComponent(jsonifyComplexType(v))); 45 | } catch (err) { 46 | debug('jsonifyComplexType failed: ' + err); 47 | } 48 | } 49 | }); 50 | } 51 | 52 | if (context) { 53 | lodash.forEach(context, function eachContext(v, k) { 54 | query.push(k + '=' + encodeURIComponent(jsonifyComplexType(v))); 55 | }); 56 | } 57 | 58 | if (id_val) { 59 | final_uri += '/' + id_param + '/' + id_val; 60 | } 61 | if (matrix.length > 0) { 62 | final_uri += ';' + matrix.sort().join(';'); 63 | } 64 | if (query.length > 0) { 65 | final_uri += '?' + query.sort().join('&'); 66 | } 67 | debug('constructed get uri:', final_uri); 68 | return final_uri; 69 | }; 70 | -------------------------------------------------------------------------------- /docs/fetchr.md: -------------------------------------------------------------------------------- 1 | # Fetchr API 2 | 3 | ## Constructor(options) 4 | 5 | Creates a new fetchr plugin instance with the following parameters: 6 | 7 | * `options`: An object containing the plugin settings 8 | * `options.req` (required on server): The request object. It can contain per-request/context data. 9 | * `options.xhrPath` (optional): The path for XHR requests. Will be ignored serverside. 10 | * `options.xhrTimeout` (optional): Timeout in milliseconds for all XHR requests 11 | * `options.corsPath` (optional): Base CORS path in case CORS is enabled 12 | * `options.context` (optional): The context object 13 | * `options.contextPicker` (optional): The context predicate functions, it will be applied to lodash/object/pick to pick values from context object 14 | * `options.contextPicker.GET` (optional): GET predicate function 15 | * `options.contextPicker.POST` (optional): POST predicate function 16 | 17 | ## Static Methods 18 | 19 | ### registerFetcher(service) 20 | 21 | register a service with fetchr 22 | 23 | ```js 24 | var Fetcher = require('fetchr'); 25 | 26 | Fetcher.registerFetcher(myDataService); 27 | ``` 28 | 29 | ### getFetcher(resource) 30 | 31 | getter for a service by resource 32 | 33 | ```js 34 | var Fetcher = require('fetchr'); 35 | var myDataService = { 36 | name: 'serviceResource', // resource 37 | read: function (){}// custom read logic 38 | }; 39 | 40 | Fetcher.registerFetcher(myDataService); 41 | Fetcher.getFetcher('serviceResource'); // returns myDataService 42 | ``` 43 | 44 | ### middleware 45 | 46 | getter for fetchr's express/connect middleware. 47 | 48 | ```js 49 | var Fetcher = require('fetchr'), 50 | express = require('express'), 51 | app = express(); 52 | 53 | app.use('/myCustomAPIEndpoint', Fetcher.middleware()); 54 | ``` 55 | 56 | ## Instance Methods 57 | 58 | ### read(resource, params, config, callback) 59 | 60 | Call the read method of a service. 61 | 62 | ### create(resource, params, body, config, callback) 63 | 64 | Call the create method of a service. 65 | 66 | ### update(resource, params, body, config, callback) 67 | 68 | Call the update method of a service. 69 | 70 | ### delete(resource, params, config, callback) 71 | 72 | Call the delete method of a service. 73 | 74 | ### getServiceMeta() 75 | 76 | Returns metadata for all service calls in an array format. 77 | The 0 index will be the first service call. 78 | 79 | ### updateOptions(options) 80 | 81 | Update the options of the fetchr instance. 82 | -------------------------------------------------------------------------------- /tests/mock/MockErrorService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | var MockErrorService = { 6 | name: 'mock_error_service', 7 | 8 | // ------------------------------------------------------------------ 9 | // CRUD Methods 10 | // ------------------------------------------------------------------ 11 | 12 | /** 13 | * read operation (read as in CRUD). 14 | * @method read 15 | * @param {Object} req The request object from connect/express 16 | * @param {String} resource The resource name 17 | * @param {Object} params The parameters identify the resource, and along with information 18 | * carried in query and matrix parameters in typical REST API 19 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 20 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 21 | * @static 22 | */ 23 | read: function (req, resource, params, config, callback) { 24 | callback({ 25 | statusCode: parseInt(params.statusCode), 26 | output: params.output, 27 | message: params.message, 28 | read: 'error' 29 | }, null, this.meta || params.meta); 30 | this.meta = null; 31 | }, 32 | /** 33 | * create operation (create as in CRUD). 34 | * @method create 35 | * @param {Object} req The request object from connect/express 36 | * @param {String} resource The resource name 37 | * @param {Object} params The parameters identify the resource, and along with information 38 | * carried in query and matrix parameters in typical REST API 39 | * @param {Object} body The JSON object that contains the resource data that is being created 40 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 41 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 42 | * @static 43 | */ 44 | create: function (req, resource, params, body, config, callback) { 45 | callback({ 46 | statusCode: parseInt(params.statusCode), 47 | message: params.message, 48 | output: params.output, 49 | create: 'error' 50 | }, null, this.meta || params.meta); 51 | this.meta = null; 52 | }, 53 | /** 54 | * update operation (update as in CRUD). 55 | * @method update 56 | * @param {Object} req The request object from connect/express 57 | * @param {String} resource The resource name 58 | * @param {Object} params The parameters identify the resource, and along with information 59 | * carried in query and matrix parameters in typical REST API 60 | * @param {Object} body The JSON object that contains the resource data that is being updated 61 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 62 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 63 | * @static 64 | */ 65 | update: function (req, resource, params, body, config, callback) { 66 | callback({ 67 | statusCode: parseInt(params.statusCode), 68 | message: params.message, 69 | output: params.output, 70 | update: 'error' 71 | }, null); 72 | }, 73 | /** 74 | * delete operation (delete as in CRUD). 75 | * @method delete 76 | * @param {Object} req The request object from connect/express 77 | * @param {String} resource The resource name 78 | * @param {Object} params The parameters identify the resource, and along with information 79 | * carried in query and matrix parameters in typical REST API 80 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 81 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 82 | * @static 83 | */ 84 | delete: function (req, resource, params, config, callback) { 85 | callback({ 86 | statusCode: parseInt(params.statusCode), 87 | message: params.message, 88 | output: params.output, 89 | delete: 'error' 90 | }, null); 91 | } 92 | 93 | }; 94 | 95 | module.exports = MockErrorService; 96 | -------------------------------------------------------------------------------- /tests/mock/MockService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | var debug = require('debug')('mservice'); 6 | var MockService = { 7 | name: 'mock_service', 8 | 9 | // ------------------------------------------------------------------ 10 | // CRUD Methods 11 | // ------------------------------------------------------------------ 12 | 13 | /** 14 | * read operation (read as in CRUD). 15 | * @method read 16 | * @param {Object} req The request object from connect/express 17 | * @param {String} resource The resource name 18 | * @param {Object} params The parameters identify the resource, and along with information 19 | * carried in query and matrix parameters in typical REST API 20 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 21 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 22 | * @static 23 | */ 24 | read: function (req, resource, params, config, callback) { 25 | debug([].splice.call(arguments, 1)); 26 | callback(null, { 27 | operation: { 28 | name: 'read', 29 | success: true 30 | }, 31 | args: { 32 | resource: resource, 33 | params: params 34 | } 35 | }, this.meta || params.meta); 36 | this.meta = null; 37 | }, 38 | /** 39 | * create operation (create as in CRUD). 40 | * @method create 41 | * @param {Object} req The request object from connect/express 42 | * @param {String} resource The resource name 43 | * @param {Object} params The parameters identify the resource, and along with information 44 | * carried in query and matrix parameters in typical REST API 45 | * @param {Object} body The JSON object that contains the resource data that is being created 46 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 47 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 48 | * @static 49 | */ 50 | create: function (req, resource, params, body, config, callback) { 51 | callback(null, { 52 | operation: { 53 | name: 'create', 54 | success: true 55 | }, 56 | args: { 57 | resource: resource, 58 | params: params 59 | } 60 | }, this.meta || params.meta); 61 | this.meta = null; 62 | }, 63 | /** 64 | * update operation (update as in CRUD). 65 | * @method update 66 | * @param {Object} req The request object from connect/express 67 | * @param {String} resource The resource name 68 | * @param {Object} params The parameters identify the resource, and along with information 69 | * carried in query and matrix parameters in typical REST API 70 | * @param {Object} body The JSON object that contains the resource data that is being updated 71 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 72 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 73 | * @static 74 | */ 75 | update: function (req, resource, params, body, config, callback) { 76 | callback(null, { 77 | operation: { 78 | name: 'update', 79 | success: true 80 | }, 81 | args: { 82 | resource: resource, 83 | params: params 84 | } 85 | }, this.meta || params.meta); 86 | this.meta = null; 87 | }, 88 | /** 89 | * delete operation (delete as in CRUD). 90 | * @method delete 91 | * @param {Object} req The request object from connect/express 92 | * @param {String} resource The resource name 93 | * @param {Object} params The parameters identify the resource, and along with information 94 | * carried in query and matrix parameters in typical REST API 95 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 96 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 97 | * @static 98 | */ 99 | 'delete': function (req, resource, params, config, callback) { 100 | callback(null, { 101 | operation: { 102 | name: 'delete', 103 | success: true 104 | }, 105 | args: { 106 | resource: resource, 107 | params: params 108 | } 109 | }, this.meta || params.meta); 110 | this.meta = null; 111 | } 112 | 113 | }; 114 | 115 | module.exports = MockService; 116 | -------------------------------------------------------------------------------- /libs/util/http.client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*jslint nomen:true,plusplus:true*/ 6 | /** 7 | * @module rest-http 8 | */ 9 | 10 | /* 11 | * Default configurations: 12 | * timeout: timeout (in ms) for each request 13 | * retry: retry related settings, such as retry interval amount (in ms), max_retries. 14 | * Note that only retry only applies on GET. 15 | */ 16 | var _ = { 17 | forEach: require('lodash/forEach'), 18 | some: require('lodash/some'), 19 | delay: require('lodash/delay'), 20 | isNumber: require('lodash/isNumber') 21 | }, 22 | DEFAULT_CONFIG = { 23 | retry: { 24 | interval: 200, 25 | max_retries: 0 26 | } 27 | }, 28 | CONTENT_TYPE = 'Content-Type', 29 | TYPE_JSON = 'application/json', 30 | METHOD_GET = 'GET', 31 | METHOD_PUT = 'PUT', 32 | METHOD_POST = 'POST', 33 | METHOD_DELETE = 'DELETE', 34 | NULL = null, 35 | xhr = require('xhr'); 36 | 37 | //trim polyfill, maybe pull from npm later 38 | if (!String.prototype.trim) { 39 | String.prototype.trim = function () { 40 | return this.replace(/^\s+|\s+$/g, ''); 41 | }; 42 | } 43 | 44 | function normalizeHeaders(headers, method, isCors) { 45 | var normalized = {}; 46 | if (!isCors) { 47 | normalized['X-Requested-With'] = 'XMLHttpRequest'; 48 | } 49 | var needContentType = (method === METHOD_PUT || method === METHOD_POST); 50 | _.forEach(headers, function (v, field) { 51 | if (field.toLowerCase() === 'content-type') { 52 | if (needContentType) { 53 | normalized[CONTENT_TYPE] = v; 54 | } 55 | } else { 56 | normalized[field] = v; 57 | } 58 | }); 59 | 60 | if (needContentType && !normalized[CONTENT_TYPE]) { 61 | normalized[CONTENT_TYPE] = TYPE_JSON; 62 | } 63 | 64 | return normalized; 65 | } 66 | 67 | function isContentTypeJSON(headers) { 68 | if (!headers[CONTENT_TYPE]) { 69 | return false; 70 | } 71 | 72 | return _.some(headers[CONTENT_TYPE].split(';'), function (part) { 73 | return part.trim().toLowerCase() === TYPE_JSON; 74 | }); 75 | } 76 | 77 | function shouldRetry(method, config, statusCode) { 78 | var isIdempotent = (method === METHOD_GET || method === METHOD_PUT || method === METHOD_DELETE); 79 | if (!isIdempotent && !config.unsafeAllowRetry) { 80 | return false; 81 | } 82 | if ((statusCode !== 0 && statusCode !== 408 && statusCode !== 999) || config.tmp.retry_counter >= config.retry.max_retries) { 83 | return false; 84 | } 85 | config.tmp.retry_counter++; 86 | config.retry.interval = config.retry.interval * 2; 87 | return true; 88 | } 89 | 90 | function mergeConfig(config) { 91 | var cfg = { 92 | unsafeAllowRetry: config.unsafeAllowRetry || false, 93 | retry: { 94 | interval: DEFAULT_CONFIG.retry.interval, 95 | max_retries: DEFAULT_CONFIG.retry.max_retries 96 | } 97 | }, // Performant-but-verbose way of cloning the default config as base 98 | timeout, 99 | interval, 100 | maxRetries; 101 | 102 | if (config) { 103 | timeout = config.timeout || config.xhrTimeout; 104 | timeout = parseInt(timeout, 10); 105 | if (_.isNumber(timeout) && timeout > 0) { 106 | cfg.timeout = timeout; 107 | } 108 | 109 | if (config.retry) { 110 | interval = parseInt(config.retry && config.retry.interval, 10); 111 | if (_.isNumber(interval) && interval > 0) { 112 | cfg.retry.interval = interval; 113 | } 114 | maxRetries = parseInt(config.retry && config.retry.max_retries, 10); 115 | if (_.isNumber(maxRetries) && maxRetries >= 0) { 116 | cfg.retry.max_retries = maxRetries; 117 | } 118 | } 119 | 120 | // tmp stores transient state data, such as retry count 121 | if (config.tmp) { 122 | cfg.tmp = config.tmp; 123 | } 124 | } 125 | 126 | return cfg; 127 | } 128 | 129 | function doXhr(method, url, headers, data, config, callback) { 130 | var options, timeout; 131 | 132 | headers = normalizeHeaders(headers, method, config.cors); 133 | config = mergeConfig(config); 134 | // use config.tmp to store temporary values 135 | config.tmp = config.tmp || {retry_counter: 0}; 136 | 137 | timeout = config.timeout; 138 | options = { 139 | method : method, 140 | timeout : timeout, 141 | headers: headers, 142 | useXDR: config.useXDR, 143 | on : { 144 | success : function (err, response) { 145 | callback(NULL, response); 146 | }, 147 | failure : function (err, response) { 148 | if (!shouldRetry(method, config, response.statusCode)) { 149 | callback(err); 150 | } else { 151 | _.delay( 152 | function retryXHR() { doXhr(method, url, headers, data, config, callback); }, 153 | config.retry.interval 154 | ); 155 | } 156 | } 157 | } 158 | }; 159 | if (data !== undefined && data !== NULL) { 160 | options.data = isContentTypeJSON(headers) ? JSON.stringify(data) : data; 161 | } 162 | io(url, options); 163 | } 164 | 165 | function io(url, options) { 166 | xhr({ 167 | url: url, 168 | method: options.method || METHOD_GET, 169 | timeout: options.timeout, 170 | headers: options.headers, 171 | body: options.data, 172 | useXDR: options.cors 173 | }, function (err, resp, body) { 174 | var status = resp.statusCode; 175 | var errMessage, errBody; 176 | 177 | if (!err && (status === 0 || (status >= 400 && status < 600))) { 178 | if (typeof body === 'string') { 179 | try { 180 | errBody = JSON.parse(body); 181 | if (errBody.message) { 182 | errMessage = errBody.message; 183 | } else { 184 | errMessage = body; 185 | } 186 | } catch(e) { 187 | errMessage = body; 188 | } 189 | } else { 190 | errMessage = status ? 'Error ' + status : 'Internal Fetchr XMLHttpRequest Error'; 191 | } 192 | 193 | err = new Error(errMessage); 194 | err.statusCode = status; 195 | err.body = errBody || body; 196 | if (408 === status || 0 === status) { 197 | err.timeout = options.timeout; 198 | } 199 | } 200 | 201 | resp.responseText = body; 202 | 203 | if (err) { 204 | // getting detail info from xhr module 205 | err.rawRequest = resp.rawRequest; 206 | err.url = resp.url; 207 | options.on.failure.call(null, err, resp); 208 | } else { 209 | options.on.success.call(null, null, resp); 210 | } 211 | }); 212 | } 213 | 214 | /** 215 | * @class REST.HTTP 216 | */ 217 | module.exports = { 218 | /** 219 | * @method get 220 | * @public 221 | * @param {String} url 222 | * @param {Object} headers 223 | * @param {Object} config The config object. 224 | * @param {Number} [config.timeout=3000] Timeout (in ms) for each request 225 | * @param {Object} config.retry Retry config object. 226 | * @param {Number} [config.retry.interval=200] The start interval unit (in ms). 227 | * @param {Number} [config.retry.max_retries=2] Number of max retries. 228 | * @param {Boolean} [config.cors] Whether to enable CORS & use XDR on IE8/9. 229 | * @param {Function} callback The callback function, with two params (error, response) 230 | */ 231 | get : function (url, headers, config, callback) { 232 | doXhr(METHOD_GET, url, headers, NULL, config, callback); 233 | }, 234 | 235 | /** 236 | * @method put 237 | * @param {String} url 238 | * @param {Object} headers 239 | * @param {Mixed} data 240 | * @param {Object} config The config object. No retries for PUT. 241 | * @param {Number} [config.timeout=3000] Timeout (in ms) for each request 242 | * @param {Number} [config.retry.interval=200] The start interval unit (in ms). 243 | * @param {Number} [config.retry.max_retries=2] Number of max retries. 244 | * @param {Boolean} [config.cors] Whether to enable CORS & use XDR on IE8/9. 245 | * @param {Function} callback The callback function, with two params (error, response) 246 | */ 247 | put : function (url, headers, data, config, callback) { 248 | doXhr(METHOD_PUT, url, headers, data, config, callback); 249 | }, 250 | 251 | /** 252 | * @method post 253 | * @param {String} url 254 | * @param {Object} headers 255 | * @param {Mixed} data 256 | * @param {Object} config The config object. No retries for POST. 257 | * @param {Number} [config.timeout=3000] Timeout (in ms) for each request 258 | * @param {Boolean} [config.unsafeAllowRetry=false] Whether to allow retrying this post. 259 | * @param {Number} [config.retry.interval=200] The start interval unit (in ms). 260 | * @param {Number} [config.retry.max_retries=2] Number of max retries. 261 | * @param {Boolean} [config.cors] Whether to enable CORS & use XDR on IE8/9. 262 | * @param {Function} callback The callback function, with two params (error, response) 263 | */ 264 | post : function (url, headers, data, config, callback) { 265 | doXhr(METHOD_POST, url, headers, data, config, callback); 266 | }, 267 | 268 | /** 269 | * @method delete 270 | * @param {String} url 271 | * @param {Object} headers 272 | * @param {Object} config The config object. No retries for DELETE. 273 | * @param {Number} [config.timeout=3000] Timeout (in ms) for each request 274 | * @param {Number} [config.retry.interval=200] The start interval unit (in ms). 275 | * @param {Number} [config.retry.max_retries=2] Number of max retries. 276 | * @param {Boolean} [config.cors] Whether to enable CORS & use XDR on IE8/9. 277 | * @param {Function} callback The callback function, with two params (error, response) 278 | */ 279 | 'delete' : function (url, headers, config, callback) { 280 | doXhr(METHOD_DELETE, url, headers, NULL, config, callback); 281 | } 282 | }; 283 | -------------------------------------------------------------------------------- /tests/unit/libs/util/http.client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*jshint expr:true*/ 6 | /*globals before,after,describe,it,beforeEach */ 7 | 'use strict'; 8 | 9 | var expect = require('chai').expect; 10 | var mockery = require('mockery'); 11 | var http; 12 | var xhrOptions; 13 | var mockResponse; 14 | var mockBody = ''; 15 | 16 | describe('Client HTTP', function () { 17 | 18 | before(function () { 19 | mockery.enable({ 20 | useCleanCache: true, 21 | warnOnUnregistered: false 22 | }); 23 | mockery.resetCache(); 24 | mockBody = ''; 25 | mockery.registerMock('xhr', function mockXhr(options, callback) { 26 | xhrOptions.push(options); 27 | callback(null, mockResponse, mockBody); 28 | }); 29 | http = require('../../../../libs/util/http.client.js'); 30 | }); 31 | 32 | after(function() { 33 | mockBody = ''; 34 | mockery.deregisterAll(); 35 | }); 36 | 37 | describe('#Successful requests', function () { 38 | beforeEach(function () { 39 | mockResponse = { 40 | statusCode: 200 41 | }; 42 | mockBody = 'BODY'; 43 | xhrOptions = []; 44 | }); 45 | 46 | it('GET', function (done) { 47 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response) { 48 | expect(xhrOptions.length).to.equal(1); 49 | var options = xhrOptions[0]; 50 | expect(options.url).to.equal('/url'); 51 | expect(options.headers['X-Requested-With']).to.equal('XMLHttpRequest'); 52 | expect(options.headers['X-Foo']).to.equal('foo'); 53 | expect(options.method).to.equal('GET'); 54 | expect(err).to.equal(null); 55 | expect(response.statusCode).to.equal(200); 56 | expect(response.responseText).to.equal('BODY'); 57 | done(); 58 | }); 59 | }); 60 | 61 | it('PUT', function (done) { 62 | http.put('/url', {'X-Foo': 'foo'}, {data: 'data'}, {}, function () { 63 | expect(xhrOptions.length).to.equal(1); 64 | var options = xhrOptions[0]; 65 | expect(options.url).to.equal('/url'); 66 | expect(options.headers['X-Requested-With']).to.equal('XMLHttpRequest'); 67 | expect(options.headers['X-Foo']).to.equal('foo'); 68 | expect(options.method).to.equal('PUT'); 69 | expect(options.body).to.eql('{"data":"data"}'); 70 | done(); 71 | }); 72 | }); 73 | 74 | it('POST', function (done) { 75 | http.post('/url', {'X-Foo': 'foo'}, {data: 'data'}, {}, function () { 76 | expect(xhrOptions.length).to.equal(1); 77 | var options = xhrOptions[0]; 78 | expect(options.url).to.equal('/url'); 79 | expect(options.headers['X-Requested-With']).to.equal('XMLHttpRequest'); 80 | expect(options.headers['X-Foo']).to.equal('foo'); 81 | expect(options.method).to.equal('POST'); 82 | expect(options.body).to.eql('{"data":"data"}'); 83 | done(); 84 | }); 85 | }); 86 | 87 | it('DELETE', function (done) { 88 | http['delete']('/url', {'X-Foo': 'foo'}, {}, function () { 89 | expect(xhrOptions.length).to.equal(1); 90 | var options = xhrOptions[0]; 91 | expect(options.url).to.equal('/url'); 92 | expect(options.headers['X-Requested-With']).to.equal('XMLHttpRequest'); 93 | expect(options.headers['X-Foo']).to.equal('foo'); 94 | expect(options.method).to.equal('DELETE'); 95 | done(); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('#Successful CORS requests', function () { 101 | beforeEach(function () { 102 | mockResponse = { 103 | statusCode: 200 104 | }; 105 | mockBody = 'BODY'; 106 | xhrOptions = []; 107 | }); 108 | 109 | it('GET', function (done) { 110 | http.get('/url', {'X-Foo': 'foo'}, {cors: true}, function (err, response) { 111 | expect(xhrOptions.length).to.equal(1); 112 | var options = xhrOptions[0]; 113 | expect(options.url).to.equal('/url'); 114 | expect(options.headers).to.not.have.property('X-Requested-With'); 115 | expect(options.headers['X-Foo']).to.equal('foo'); 116 | expect(options.method).to.equal('GET'); 117 | expect(err).to.equal(null); 118 | expect(response.statusCode).to.equal(200); 119 | expect(response.responseText).to.equal('BODY'); 120 | done(); 121 | }); 122 | }); 123 | 124 | it('PUT', function (done) { 125 | http.put('/url', {'X-Foo': 'foo'}, {data: 'data'}, {cors: true}, function () { 126 | expect(xhrOptions.length).to.equal(1); 127 | var options = xhrOptions[0]; 128 | expect(options.url).to.equal('/url'); 129 | expect(options.headers).to.not.have.property('X-Requested-With'); 130 | expect(options.headers['X-Foo']).to.equal('foo'); 131 | expect(options.method).to.equal('PUT'); 132 | expect(options.body).to.eql('{"data":"data"}'); 133 | done(); 134 | }); 135 | }); 136 | 137 | it('POST', function (done) { 138 | http.post('/url', {'X-Foo': 'foo'}, {data: 'data'}, {cors: true}, function () { 139 | expect(xhrOptions.length).to.equal(1); 140 | var options = xhrOptions[0]; 141 | expect(options.url).to.equal('/url'); 142 | expect(options.headers).to.not.have.property('X-Requested-With'); 143 | expect(options.headers['X-Foo']).to.equal('foo'); 144 | expect(options.method).to.equal('POST'); 145 | expect(options.body).to.eql('{"data":"data"}'); 146 | done(); 147 | }); 148 | }); 149 | 150 | it('DELETE', function (done) { 151 | http['delete']('/url', {'X-Foo': 'foo'}, {cors: true}, function () { 152 | expect(xhrOptions.length).to.equal(1); 153 | var options = xhrOptions[0]; 154 | expect(options.url).to.equal('/url'); 155 | expect(options.headers).to.not.have.property('X-Requested-With'); 156 | expect(options.headers['X-Foo']).to.equal('foo'); 157 | expect(options.method).to.equal('DELETE'); 158 | done(); 159 | }); 160 | }); 161 | }); 162 | 163 | describe('#400 requests', function () { 164 | beforeEach(function () { 165 | xhrOptions = []; 166 | mockResponse = { 167 | statusCode: 400 168 | }; 169 | }); 170 | 171 | it('GET with no response', function (done) { 172 | mockBody = undefined; 173 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response, body) { 174 | expect(err.message).to.equal('Error 400'); 175 | expect(err.statusCode).to.equal(400); 176 | expect(err.body).to.equal(undefined); 177 | done(); 178 | }); 179 | }); 180 | 181 | it('GET with empty response', function (done) { 182 | mockBody = ''; 183 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response, body) { 184 | expect(err.message).to.equal(''); 185 | expect(err.statusCode).to.equal(400); 186 | expect(err.body).to.equal(''); 187 | done(); 188 | }); 189 | }); 190 | 191 | it('GET with JSON response containing message attribute', function (done) { 192 | mockBody = '{"message":"some body content"}'; 193 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response, body) { 194 | expect(err.message).to.equal('some body content'); 195 | expect(err.statusCode).to.equal(400); 196 | expect(err.body).to.deep.equal({ 197 | message: 'some body content' 198 | }); 199 | done(); 200 | }); 201 | }); 202 | 203 | it('GET with JSON response not containing message attribute', function (done) { 204 | mockBody = '{"other":"some body content"}'; 205 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response, body) { 206 | expect(err.message).to.equal(mockBody); 207 | expect(err.statusCode).to.equal(400); 208 | expect(err.body).to.deep.equal({ 209 | other: "some body content" 210 | }); 211 | done(); 212 | }); 213 | }); 214 | 215 | // Need to test plain text response 216 | // as some servers (e.g. node running in IIS) 217 | // may remove body content 218 | // and replace it with 'Bad Request' 219 | // if not configured to allow content throughput 220 | it('GET with plain text', function (done) { 221 | mockBody = 'Bad Request'; 222 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response, body) { 223 | expect(err.message).to.equal(mockBody); 224 | expect(err.statusCode).to.equal(400); 225 | expect(err.body).to.equal(mockBody); 226 | done(); 227 | }); 228 | }); 229 | }); 230 | 231 | describe('#408 requests', function () { 232 | beforeEach(function () { 233 | xhrOptions = []; 234 | mockBody = 'BODY'; 235 | mockResponse = { 236 | statusCode: 408 237 | }; 238 | }); 239 | 240 | it('GET with no retry', function (done) { 241 | http.get('/url', {'X-Foo': 'foo'}, {}, function (err, response, body) { 242 | var options = xhrOptions[0]; 243 | expect(xhrOptions.length).to.equal(1); 244 | expect(options.url).to.equal('/url'); 245 | expect(options.headers['X-Requested-With']).to.equal('XMLHttpRequest'); 246 | expect(options.headers['X-Foo']).to.equal('foo'); 247 | expect(options.method).to.equal('GET'); 248 | expect(err.message).to.equal('BODY'); 249 | expect(err.statusCode).to.equal(408); 250 | expect(err.body).to.equal('BODY'); 251 | done(); 252 | }); 253 | }); 254 | 255 | it('GET with retry', function (done) { 256 | http.get('/url', {'X-Foo': 'foo'}, { 257 | timeout: 2000, 258 | retry: { 259 | interval: 200, 260 | max_retries: 1 261 | } 262 | }, function (err, response, body) { 263 | expect(xhrOptions.length).to.equal(2); 264 | var options = xhrOptions[0]; 265 | expect(options.url).to.equal('/url'); 266 | expect(options.headers['X-Requested-With']).to.equal('XMLHttpRequest'); 267 | expect(options.headers['X-Foo']).to.equal('foo'); 268 | expect(options.method).to.equal('GET'); 269 | expect(options.timeout).to.equal(2000); 270 | expect(err.message).to.equal('BODY'); 271 | expect(err.statusCode).to.equal(408); 272 | expect(err.body).to.equal('BODY'); 273 | expect(xhrOptions[0]).to.eql(xhrOptions[1]); 274 | done(); 275 | }); 276 | }); 277 | }); 278 | 279 | describe('#Timeout', function () { 280 | var config; 281 | 282 | beforeEach(function () { 283 | mockResponse = { 284 | statusCode: 200 285 | }; 286 | mockBody = 'BODY'; 287 | xhrOptions = []; 288 | }); 289 | 290 | describe('#No timeout set for individual call', function () { 291 | beforeEach(function () { 292 | config = {xhrTimeout: 3000}; 293 | }); 294 | 295 | it('should use xhrTimeout for GET', function (done) { 296 | http.get('/url', {'X-Foo': 'foo'}, config, function (err, response) { 297 | var options = xhrOptions[0]; 298 | expect(options.timeout).to.equal(3000); 299 | done(); 300 | }); 301 | }); 302 | 303 | it('should use xhrTimeout for PUT', function (done) { 304 | http.put('/url', {'X-Foo': 'foo'}, {data: 'data'}, config, function () { 305 | var options = xhrOptions[0]; 306 | expect(options.timeout).to.equal(3000); 307 | done(); 308 | }); 309 | }); 310 | 311 | it('should use xhrTimeout for POST', function (done) { 312 | http.post('/url', {'X-Foo': 'foo'}, {data: 'data'}, config, function () { 313 | var options = xhrOptions[0]; 314 | expect(options.timeout).to.equal(3000); 315 | done(); 316 | }); 317 | }); 318 | 319 | it('should use xhrTimeout for DELETE', function (done) { 320 | http['delete']('/url', {'X-Foo': 'foo'}, config, function () { 321 | var options = xhrOptions[0]; 322 | expect(options.timeout).to.equal(3000); 323 | done(); 324 | }); 325 | }); 326 | }); 327 | 328 | describe('#Timeout set for individual call', function () { 329 | beforeEach(function () { 330 | config = {xhrTimeout: 3000, timeout: 6000}; 331 | }); 332 | 333 | it('should override default xhrTimeout for GET', function (done) { 334 | http.get('/url', {'X-Foo': 'foo'}, config, function (err, response) { 335 | var options = xhrOptions[0]; 336 | expect(options.timeout).to.equal(6000); 337 | done(); 338 | }); 339 | }); 340 | 341 | it('should override default xhrTimeout for PUT', function (done) { 342 | http.put('/url', {'X-Foo': 'foo'}, {data: 'data'}, config, function () { 343 | var options = xhrOptions[0]; 344 | expect(options.timeout).to.equal(6000); 345 | done(); 346 | }); 347 | }); 348 | 349 | it('should override default xhrTimeout for POST', function (done) { 350 | http.post('/url', {'X-Foo': 'foo'}, {data: 'data'}, config, function () { 351 | var options = xhrOptions[0]; 352 | expect(options.timeout).to.equal(6000); 353 | done(); 354 | }); 355 | }); 356 | 357 | it('should override default xhrTimeout for DELETE', function (done) { 358 | http['delete']('/url', {'X-Foo': 'foo'}, config, function () { 359 | var options = xhrOptions[0]; 360 | expect(options.timeout).to.equal(6000); 361 | done(); 362 | }); 363 | }); 364 | }); 365 | }); 366 | }); 367 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fetchr 2 | 3 | [![npm version](https://badge.fury.io/js/fetchr.svg)](http://badge.fury.io/js/fetchr) 4 | [![Build Status](https://travis-ci.org/yahoo/fetchr.svg?branch=master)](https://travis-ci.org/yahoo/fetchr) 5 | [![Dependency Status](https://david-dm.org/yahoo/fetchr.svg)](https://david-dm.org/yahoo/fetchr) 6 | [![devDependency Status](https://david-dm.org/yahoo/fetchr/dev-status.svg)](https://david-dm.org/yahoo/fetchr#info=devDependencies) 7 | [![Coverage Status](https://coveralls.io/repos/yahoo/fetchr/badge.png?branch=master)](https://coveralls.io/r/yahoo/fetchr?branch=master) 8 | 9 | Universal data access layer for web applications. 10 | 11 | Typically on the server, you call your API or database directly to fetch some data. However, on the client, you cannot always call your services in the same way (i.e, cross domain policies). Instead, XHR requests need to be made to the server which get forwarded to your service. 12 | 13 | Having to write code differently for both environments is duplicative and error prone. Fetchr provides an abstraction layer over your data service calls so that you can fetch data using the same API on the server and client side. 14 | 15 | ## Install 16 | 17 | ``` 18 | npm install fetchr --save 19 | ``` 20 | 21 | ## Setup 22 | 23 | Follow the steps below to setup Fetchr properly. This assumes you are using the [Express](https://www.npmjs.com/package/express) framework. 24 | 25 | ### 1. Configure Server 26 | 27 | On the server side, add the Fetchr middleware into your express app at a custom API endpoint. 28 | 29 | Fetchr middleware expects that you're using the [`body-parser`](https://github.com/expressjs/body-parser) middleware (or an alternative middleware that populates `req.body`) before you use Fetchr middleware. 30 | 31 | ```js 32 | var express = require('express'); 33 | var Fetcher = require('fetchr'); 34 | var bodyParser = require('body-parser'); 35 | var app = express(); 36 | 37 | // you need to use body-parser middleware before fetcher middleware 38 | app.use(bodyParser.json()); 39 | 40 | app.use('/myCustomAPIEndpoint', Fetcher.middleware()); 41 | ``` 42 | 43 | ### 2. Configure Client 44 | 45 | On the client side, it is necessary for the `xhrPath` option to match the path where the middleware was mounted in the previous step 46 | 47 | `xhrPath` is an optional config property that allows you to customize the endpoint to your services, defaults to `/api`. 48 | 49 | ```js 50 | var Fetcher = require('fetchr'); 51 | var fetcher = new Fetcher({ 52 | xhrPath: '/myCustomAPIEndpoint' 53 | }); 54 | ``` 55 | 56 | ### 3. Register data services 57 | 58 | You will need to register any data services that you wish to use in your application. 59 | The interface for your service will be an object that must define a `name` property and at least one [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) operation. 60 | The `name` propety will be used when you call one of the CRUD operations. 61 | 62 | ```js 63 | // app.js 64 | var Fetcher = require('fetchr'); 65 | var myDataService = require('./dataService'); 66 | Fetcher.registerService(myDataService); 67 | ``` 68 | 69 | ```js 70 | // dataService.js 71 | module.exports = { 72 | // name is required 73 | name: 'data_service', 74 | // at least one of the CRUD methods is required 75 | read: function(req, resource, params, config, callback) { 76 | //... 77 | }, 78 | // other methods 79 | // create: function(req, resource, params, body, config, callback) {}, 80 | // update: function(req, resource, params, body, config, callback) {}, 81 | // delete: function(req, resource, params, config, callback) {} 82 | } 83 | ``` 84 | 85 | ### 4. Instantiating the Fetchr Class 86 | 87 | Data services might need access to each individual request, for example, to get the current logged in user's session. 88 | For this reason, Fetcher will have to be instantiated once per request. 89 | 90 | On the serverside, this requires fetcher to be instantiated per request, in express middleware. 91 | On the clientside, this only needs to happen on page load. 92 | 93 | 94 | ```js 95 | // app.js - server 96 | var express = require('express'); 97 | var Fetcher = require('fetchr'); 98 | var app = express(); 99 | var myDataService = require('./dataService'); 100 | 101 | // register the service 102 | Fetcher.registerService(myDataService); 103 | 104 | // register the middleware 105 | app.use('/myCustomAPIEndpoint', Fetcher.middleware()); 106 | 107 | app.use(function(req, res, next) { 108 | // instantiated fetcher with access to req object 109 | var fetcher = new Fetcher({ 110 | xhrPath: '/myCustomAPIEndpoint', // xhrPath will be ignored on the serverside fetcher instantiation 111 | req: req 112 | }); 113 | 114 | // perform read call to get data 115 | fetcher 116 | .read('data_service') 117 | .params({id: ###}) 118 | .end(function (err, data, meta) { 119 | // handle err and/or data returned from data fetcher in this callback 120 | }); 121 | }); 122 | ``` 123 | 124 | ```js 125 | // app.js - client 126 | var Fetcher = require('fetchr'); 127 | var fetcher = new Fetcher({ 128 | xhrPath: '/myCustomAPIEndpoint' // xhrPath is REQUIRED on the clientside fetcher instantiation 129 | }); 130 | fetcher 131 | .read('data_api_fetcher') 132 | .params({id: ###}) 133 | .end(function (err, data, meta) { 134 | // handle err and/or data returned from data fetcher in this callback 135 | }); 136 | 137 | // for create you can use the body() method to pass data 138 | fetcher 139 | .create('data_api_create') 140 | .body({"some":"data"}) 141 | .end(function (err, data, meta) { 142 | // handle err and/or data returned from data fetcher in this callback 143 | }); 144 | ``` 145 | 146 | 147 | 148 | ## Usage Examples 149 | 150 | See the [simple example](https://github.com/yahoo/fetchr/tree/master/examples/simple). 151 | 152 | ## Service Metadata 153 | 154 | Service calls on the client transparently become xhr requests. 155 | It is a good idea to set cache headers on common xhr calls. 156 | You can do so by providing a third parameter in your service's callback. 157 | If you want to look at what headers were set by the service you just called, 158 | simply inspect the third parameter in the callback. 159 | 160 | Note: If you're using promises, the metadata will be available on the `meta` 161 | property of the resolved value. 162 | 163 | ```js 164 | // dataService.js 165 | module.exports = { 166 | name: 'data_service', 167 | read: function(req, resource, params, config, callback) { 168 | // business logic 169 | var data = 'response'; 170 | var meta = { 171 | headers: { 172 | 'cache-control': 'public, max-age=3600' 173 | }, 174 | statusCode: 200 // You can even provide a custom statusCode for the xhr response 175 | }; 176 | callback(null, data, meta); 177 | } 178 | } 179 | ``` 180 | 181 | ```js 182 | fetcher 183 | .read('data_service') 184 | .params({id: ###}) 185 | .end(function (err, data, meta) { 186 | // data will be 'response' 187 | // meta will have the header and statusCode from above 188 | }); 189 | ``` 190 | 191 | There is a convenience method called `fetcher.getServiceMeta` on the fetchr instance. 192 | This method will return the metadata for all the calls that have happened so far 193 | in an array format. 194 | In the server, this will include all service calls for the current request. 195 | In the client, this will include all service calls for the current session. 196 | 197 | 198 | ## Updating Configuration 199 | 200 | Usually you instantiate fetcher with some default options for the entire browser session, 201 | but there might be cases where you want to update these options later in the same session. 202 | 203 | You can do that with the `updateOptions` method: 204 | 205 | ```js 206 | // Start 207 | var fetcher = new Fetcher({ 208 | xhrPath: '/myCustomAPIEndpoint', 209 | xhrTimeout: 2000 210 | }); 211 | 212 | // Later, you may want to update the xhrTimeout 213 | fetcher.updateOptions({ 214 | xhrTimeout: 4000 215 | }); 216 | ``` 217 | 218 | 219 | ## Error Handling 220 | 221 | When an error occurs in your Fetchr CRUD method, you should return an error object to the callback. The error object should contain a `statusCode` (default 400) and `output` property that contains a JSON serializable object which will be sent to the client. 222 | 223 | ```js 224 | module.exports = { 225 | name: 'FooService', 226 | read: function create(req, resource, params, configs, callback) { 227 | var err = new Error('it failed'); 228 | err.statusCode = 404; 229 | err.output = { message: "Not found", more: "meta data" }; 230 | return callback(err); 231 | } 232 | }; 233 | ``` 234 | 235 | ## XHR Timeouts 236 | 237 | `xhrTimeout` is an optional config property that allows you to set timeout (in ms) for all clientside requests, defaults to `3000`. 238 | On the clientside, xhrPath and xhrTimeout will be used for XHR requests. 239 | On the serverside, xhrPath and xhrTimeout are not needed and are ignored. 240 | 241 | ```js 242 | var Fetcher = require('fetchr'); 243 | var fetcher = new Fetcher({ 244 | xhrPath: '/myCustomAPIEndpoint', 245 | xhrTimeout: 4000 246 | }); 247 | ``` 248 | 249 | If you have an individual request that you need to ensure has a specific timeout you can do that via the `timeout` option in `clientConfig`: 250 | 251 | ```js 252 | fetcher 253 | .read('someData') 254 | .params({id: ###}) 255 | .clientConfig({timeout: 5000}) // wait 5 seconds for this request before timing it out 256 | .end(function (err, data, meta) { 257 | // handle err and/or data returned from data fetcher in this callback 258 | }); 259 | ``` 260 | 261 | ## XHR Response Formatting 262 | 263 | For some applications, there may be a situation where you need to modify an XHR response before it is passed to the client. Typically, you would apply your modifications in the service itself. However, if you want to modify the XHR responses across many services (i.e. add debug information), then you can use the `responseFormatter` option. 264 | 265 | `responseFormatter` is a function that is passed into the `Fetcher.middleware` method. It is passed three arguments, the request object, response object and the service response object (i.e. the data returned from your service). The `responseFormatter` function can then modify the service response to add additional information. 266 | 267 | Take a look at the example below: 268 | 269 | ```js 270 | /** 271 | Using the app.js from above, you can modify the Fetcher.middleware 272 | method to pass in the responseFormatter function. 273 | */ 274 | app.use('/myCustomAPIEndpoint', Fetcher.middleware({ 275 | responseFormatter: function (req, res, data) { 276 | data.debug = 'some debug information'; 277 | return data; 278 | } 279 | })); 280 | ``` 281 | 282 | Now when an XHR request is performed, your response will contain the `debug` property added above. 283 | 284 | ## CORS Support 285 | 286 | Fetchr provides [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) support by allowing you to pass the full origin host into `corsPath` option. 287 | 288 | For example: 289 | 290 | ```js 291 | var Fetcher = require('fetchr'); 292 | var fetcher = new Fetcher({ 293 | corsPath: 'http://www.foo.com', 294 | xhrPath: '/fooProxy' 295 | }); 296 | fetcher 297 | .read('service') 298 | .params({ foo: 1 }) 299 | .clientConfig({ cors: true }) 300 | .end(callbackFn); 301 | ``` 302 | 303 | Additionally, you can also customize how the GET URL is constructed by passing in the `constructGetUri` property when you execute your `read` call: 304 | 305 | ```js 306 | var qs = require('qs'); 307 | function customConstructGetUri(uri, resource, params, config) { 308 | // this refers to the Fetcher object itself that this function is invoked with. 309 | if (config.cors) { 310 | return uri + '/' + resource + '?' + qs.stringify(this.context); 311 | } 312 | // Return `falsy` value will result in `fetcher` using its internal path construction instead. 313 | } 314 | 315 | var Fetcher = require('fetchr'); 316 | var fetcher = new Fetcher({ 317 | corsPath: 'http://www.foo.com', 318 | xhrPath: '/fooProxy' 319 | }); 320 | fetcher 321 | .read('service') 322 | .params({ foo: 1 }) 323 | .clientConfig({ 324 | cors: true, 325 | constructGetUri: customConstructGetUri 326 | }) 327 | .end(callbackFn); 328 | ``` 329 | 330 | 331 | ## CSRF Protection 332 | 333 | You can protect your XHR paths from CSRF attacks by adding a middleware in front of the fetchr middleware: 334 | 335 | `app.use('/myCustomAPIEndpoint', csrf(), Fetcher.middleware());` 336 | 337 | You could use https://github.com/expressjs/csurf for this as an example. 338 | 339 | Next you need to make sure that the CSRF token is being sent with our XHR requests so that they can be validated. To do this, pass the token in as a key in the `options.context` object on the client: 340 | 341 | ```js 342 | var fetcher = new Fetcher({ 343 | xhrPath: '/myCustomAPIEndpoint', //xhrPath is REQUIRED on the clientside fetcher instantiation 344 | context: { // These context values are persisted with XHR calls as query params 345 | _csrf: 'Ax89D94j' 346 | } 347 | }); 348 | ``` 349 | 350 | This `_csrf` will be sent in all XHR requests as a query parameter so that it can be validated on the server. 351 | 352 | ## Service Call Config 353 | 354 | When calling a Fetcher service you can pass an optional config object. 355 | 356 | When this call is made from the client, the config object is used to define XHR request options and can be used to override default options: 357 | 358 | ```js 359 | //app.js - client 360 | var config = { 361 | timeout: 6000, // Timeout (in ms) for each request 362 | unsafeAllowRetry: false // for POST requests, whether to allow retrying this post 363 | }; 364 | 365 | fetcher 366 | .read('service') 367 | .params({ id: 1 }) 368 | .clientConfig(config) 369 | .end(callbackFn); 370 | ``` 371 | 372 | For requests from the server, the config object is simply passed into the service being called. 373 | 374 | ## Context Variables 375 | 376 | By Default, fetchr appends all context values to the xhr url as query params. `contextPicker` allows you to greater control over which context variables get sent as query params depending on the xhr method (`GET` or `POST`). This is useful when you want to limit the number of variables in a `GET` url in order not to accidentally [cache bust](http://webassets.readthedocs.org/en/latest/expiring.html). 377 | 378 | `contextPicker` follows the same format as the `predicate` parameter in [`lodash/pickBy`](https://lodash.com/docs#pickBy) with two arguments: `(value, key)`. 379 | 380 | ```js 381 | var fetcher = new Fetcher({ 382 | context: { // These context values are persisted with XHR calls as query params 383 | _csrf: 'Ax89D94j', 384 | device: 'desktop' 385 | }, 386 | contextPicker: { 387 | GET: function (value, key) { 388 | // for example, if you don't enable CSRF protection for GET, you are able to ignore it with the url 389 | if (key === '_csrf') { 390 | return false; 391 | } 392 | return true; 393 | } 394 | // for other method e.g., POST, if you don't define the picker, it will pick the entire context object 395 | } 396 | }); 397 | 398 | var fetcher = new Fetcher({ 399 | context: { // These context values are persisted with XHR calls as query params 400 | _csrf: 'Ax89D94j', 401 | device: 'desktop' 402 | }, 403 | contextPicker: { 404 | GET: ['device'] // predicate can be an array of strings 405 | } 406 | }); 407 | ``` 408 | 409 | ## API 410 | 411 | - [Fetchr](https://github.com/yahoo/fetchr/blob/master/docs/fetchr.md) 412 | 413 | ## License 414 | 415 | This software is free to use under the Yahoo! Inc. BSD license. 416 | See the [LICENSE file][] for license text and copyright information. 417 | 418 | [LICENSE file]: https://github.com/yahoo/fetchr/blob/master/LICENSE.md 419 | -------------------------------------------------------------------------------- /libs/fetcher.client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 6 | /*jslint plusplus:true,nomen:true */ 7 | 8 | /** 9 | * Fetcher is a CRUD interface for your data. 10 | * @module Fetcher 11 | */ 12 | var REST = require('./util/http.client'); 13 | var debug = require('debug')('FetchrClient'); 14 | var lodash = { 15 | isFunction: require('lodash/isFunction'), 16 | forEach: require('lodash/forEach'), 17 | merge: require('lodash/merge'), 18 | noop: require('lodash/noop'), 19 | pickBy: require('lodash/pickBy'), 20 | pick: require('lodash/pick') 21 | }; 22 | var DEFAULT_GUID = 'g0'; 23 | var DEFAULT_XHR_PATH = '/api'; 24 | var DEFAULT_XHR_TIMEOUT = 3000; 25 | var MAX_URI_LEN = 2048; 26 | var OP_READ = 'read'; 27 | var defaultConstructGetUri = require('./util/defaultConstructGetUri'); 28 | var Promise = global.Promise || require('es6-promise').Promise; 29 | 30 | function parseResponse(response) { 31 | if (response && response.responseText) { 32 | try { 33 | return JSON.parse(response.responseText); 34 | } catch (e) { 35 | debug('json parse failed:' + e, 'error'); 36 | return null; 37 | } 38 | } 39 | return null; 40 | } 41 | 42 | /** 43 | * Pick keys from the context object 44 | * @method pickContext 45 | * @param {Object} context context object 46 | * @param {Function|Array|String} picker picker object w/iteratee for lodash/pickBy|pick 47 | * @param {String} method method name, get or post 48 | */ 49 | function pickContext (context, picker, method) { 50 | if (picker && picker[method]) { 51 | var libPicker = lodash.isFunction(picker[method]) ? lodash.pickBy : lodash.pick; 52 | return libPicker(context, picker[method]); 53 | } 54 | return context; 55 | } 56 | 57 | /** 58 | * A RequestClient instance represents a single fetcher request. 59 | * The constructor requires `operation` (CRUD) and `resource`. 60 | * @class RequestClient 61 | * @param {String} operation The CRUD operation name: 'create|read|update|delete'. 62 | * @param {String} resource name of fetcher/service 63 | * @param {Object} options configuration options for Request 64 | * @param {Array} [options._serviceMeta] Array to hold per-request/session metadata from all service calls. 65 | * Data will be pushed on to this array while the Fetchr instance maintains the reference for this session. 66 | * 67 | * @constructor 68 | */ 69 | function Request (operation, resource, options) { 70 | if (!resource) { 71 | throw new Error('Resource is required for a fetcher request'); 72 | } 73 | 74 | this.operation = operation || OP_READ; 75 | this.resource = resource; 76 | this.options = { 77 | xhrPath: options.xhrPath || DEFAULT_XHR_PATH, 78 | xhrTimeout: options.xhrTimeout || DEFAULT_XHR_TIMEOUT, 79 | corsPath: options.corsPath, 80 | context: options.context || {}, 81 | contextPicker: options.contextPicker || {}, 82 | _serviceMeta: options._serviceMeta || [] 83 | }; 84 | this._params = {}; 85 | this._body = null; 86 | this._clientConfig = {}; 87 | } 88 | 89 | /** 90 | * Add params to this fetcher request 91 | * @method params 92 | * @memberof Request 93 | * @param {Object} params Information carried in query and matrix parameters in typical REST API 94 | * @chainable 95 | */ 96 | Request.prototype.params = function (params) { 97 | this._params = params || {}; 98 | return this; 99 | }; 100 | 101 | /** 102 | * Add body to this fetcher request 103 | * @method body 104 | * @memberof Request 105 | * @param {Object} body The JSON object that contains the resource data being updated for this request. 106 | * Not used for read and delete operations. 107 | * @chainable 108 | */ 109 | Request.prototype.body = function (body) { 110 | this._body = body || null; 111 | return this; 112 | }; 113 | 114 | /** 115 | * Add clientConfig to this fetcher request 116 | * @method clientConfig 117 | * @memberof Request 118 | * @param {Object} config config for this fetcher request 119 | * @chainable 120 | */ 121 | Request.prototype.clientConfig = function (config) { 122 | this._clientConfig = config || {}; 123 | return this; 124 | }; 125 | 126 | /** 127 | * Execute this fetcher request and call callback. 128 | * @method end 129 | * @memberof Request 130 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher/service is complete. 131 | * @async 132 | */ 133 | Request.prototype.end = function (callback) { 134 | var self = this; 135 | var promise = new Promise(function (resolve, reject) { 136 | debug('Executing request %s.%s with params %o and body %o', self.resource, self.operation, self._params, self._body); 137 | setImmediate(executeRequest, self, resolve, reject); 138 | }); 139 | 140 | promise = promise.then(function (result) { 141 | if (result.meta) { 142 | self.options._serviceMeta.push(result.meta); 143 | } 144 | return result; 145 | }); 146 | 147 | if (callback) { 148 | promise.then(function (result) { 149 | setImmediate(callback, null, result.data, result.meta); 150 | }, function (err) { 151 | setImmediate(callback, err); 152 | }); 153 | } else { 154 | return promise; 155 | } 156 | }; 157 | 158 | /** 159 | * Execute and resolve/reject this fetcher request 160 | * @method executeRequest 161 | * @param {Object} request Request instance object 162 | * @param {Function} resolve function to call when request fulfilled 163 | * @param {Function} reject function to call when request rejected 164 | */ 165 | function executeRequest (request, resolve, reject) { 166 | var clientConfig = request._clientConfig; 167 | var use_post; 168 | var allow_retry_post; 169 | var uri = clientConfig.uri; 170 | var requests; 171 | var params; 172 | var data; 173 | 174 | if (!uri) { 175 | uri = clientConfig.cors ? request.options.corsPath : request.options.xhrPath; 176 | } 177 | 178 | use_post = request.operation !== OP_READ || clientConfig.post_for_read; 179 | // We use GET request by default for READ operation, but you can override that behavior 180 | // by specifying {post_for_read: true} in your request's clientConfig 181 | if (!use_post) { 182 | var getUriFn = lodash.isFunction(clientConfig.constructGetUri) ? clientConfig.constructGetUri : defaultConstructGetUri; 183 | var get_uri = getUriFn.call(request, uri, request.resource, request._params, clientConfig, pickContext(request.options.context, request.options.contextPicker, 'GET')); 184 | /* istanbul ignore next */ 185 | if (!get_uri) { 186 | // If a custom getUriFn returns falsy value, we should run defaultConstructGetUri 187 | // TODO: Add test for this fallback 188 | get_uri = defaultConstructGetUri.call(request, uri, request.resource, request._params, clientConfig, request.options.context); 189 | } 190 | // TODO: Remove `returnMeta` feature flag after next release 191 | // This feature flag will enable the new return format for GET api requests 192 | // Whereas before any data from services was returned as is. We now return 193 | // an object with a data key containing the service response, and a meta key 194 | // containing the service's metadata response (i.e headers and statusCode). 195 | // We need this feature flag to be truly backwards compatible because it is 196 | // concievable that some active browser sessions could have the old version of 197 | // client fetcher while the server upgrades to the new version. This could be 198 | // easily fixed by refreshing the browser, but the feature flag will ensure 199 | // old fetcher clients will receive the old format and the new client will 200 | // receive the new format 201 | get_uri += (get_uri.indexOf('?') !== -1) ? '&' : '?'; 202 | get_uri += 'returnMeta=true'; 203 | if (get_uri.length <= MAX_URI_LEN) { 204 | uri = get_uri; 205 | } else { 206 | use_post = true; 207 | } 208 | } 209 | 210 | if (!use_post) { 211 | return REST.get(uri, {}, lodash.merge({xhrTimeout: request.options.xhrTimeout}, clientConfig), function getDone(err, response) { 212 | if (err) { 213 | debug('Syncing ' + request.resource + ' failed: statusCode=' + err.statusCode, 'info'); 214 | return reject(err); 215 | } 216 | resolve(parseResponse(response)); 217 | }); 218 | } 219 | 220 | // individual request is also normalized into a request hash to pass to api 221 | requests = {}; 222 | requests[DEFAULT_GUID] = { 223 | resource: request.resource, 224 | operation: request.operation, 225 | params: request._params 226 | }; 227 | if (request._body) { 228 | requests[DEFAULT_GUID].body = request._body; 229 | } 230 | data = { 231 | requests: requests, 232 | context: request.options.context 233 | }; // TODO: remove. leave here for now for backward compatibility 234 | uri = request._constructGroupUri(uri); 235 | allow_retry_post = (request.operation === OP_READ); 236 | REST.post(uri, {}, data, lodash.merge({unsafeAllowRetry: allow_retry_post, xhrTimeout: request.options.xhrTimeout}, clientConfig), function postDone(err, response) { 237 | if (err) { 238 | debug('Syncing ' + request.resource + ' failed: statusCode=' + err.statusCode, 'info'); 239 | return reject(err); 240 | } 241 | var result = parseResponse(response); 242 | if (result) { 243 | result = result[DEFAULT_GUID] || {}; 244 | } else { 245 | result = {}; 246 | } 247 | resolve(result); 248 | }); 249 | } 250 | 251 | /** 252 | * Build a final uri by adding query params to base uri from this.context 253 | * @method _constructGroupUri 254 | * @param {String} uri the base uri 255 | * @private 256 | */ 257 | Request.prototype._constructGroupUri = function (uri) { 258 | var query = []; 259 | var final_uri = uri; 260 | lodash.forEach(pickContext(this.options.context, this.options.contextPicker, 'POST'), function eachContext(v, k) { 261 | query.push(k + '=' + encodeURIComponent(v)); 262 | }); 263 | if (query.length > 0) { 264 | final_uri += '?' + query.sort().join('&'); 265 | } 266 | return final_uri; 267 | }; 268 | 269 | /** 270 | * Fetcher class for the client. Provides CRUD methods. 271 | * @class FetcherClient 272 | * @param {Object} options configuration options for Fetcher 273 | * @param {String} [options.xhrPath="/api"] The path for XHR requests 274 | * @param {Number} [options.xhrTimout=3000] Timeout in milliseconds for all XHR requests 275 | * @param {Boolean} [options.corsPath] Base CORS path in case CORS is enabled 276 | * @param {Object} [options.context] The context object that is propagated to all outgoing 277 | * requests as query params. It can contain current-session/context data that should 278 | * persist to all requests. 279 | * @param {Object} [options.contextPicker] The context picker for GET and POST, they must be 280 | * lodash pick predicate function with three arguments (value, key, object) 281 | * @param {Function|String|String[]} [options.contextPicker.GET] GET context picker 282 | * @param {Function|String|String[]} [options.contextPicker.POST] POST context picker 283 | */ 284 | 285 | function Fetcher (options) { 286 | this._serviceMeta = []; 287 | this.options = { 288 | xhrPath: options.xhrPath, 289 | xhrTimeout: options.xhrTimeout, 290 | corsPath: options.corsPath, 291 | context: options.context, 292 | contextPicker: options.contextPicker, 293 | _serviceMeta: this._serviceMeta 294 | }; 295 | } 296 | 297 | Fetcher.prototype = { 298 | // ------------------------------------------------------------------ 299 | // Data Access Wrapper Methods 300 | // ------------------------------------------------------------------ 301 | 302 | /** 303 | * create operation (create as in CRUD). 304 | * @method create 305 | * @param {String} resource The resource name 306 | * @param {Object} params The parameters identify the resource, and along with information 307 | * carried in query and matrix parameters in typical REST API 308 | * @param {Object} body The JSON object that contains the resource data that is being created 309 | * @param {Object} clientConfig The "config" object for per-request config data. 310 | * @param {Function} callback callback convention is the same as Node.js 311 | * @static 312 | */ 313 | create: function (resource, params, body, clientConfig, callback) { 314 | var request = new Request('create', resource, this.options); 315 | if (1 === arguments.length) { 316 | return request; 317 | } 318 | // TODO: Remove below this line in release after next 319 | if (typeof clientConfig === 'function') { 320 | callback = clientConfig; 321 | clientConfig = {}; 322 | } 323 | return request 324 | .params(params) 325 | .body(body) 326 | .clientConfig(clientConfig) 327 | .end(callback); 328 | }, 329 | 330 | /** 331 | * read operation (read as in CRUD). 332 | * @method read 333 | * @param {String} resource The resource name 334 | * @param {Object} params The parameters identify the resource, and along with information 335 | * carried in query and matrix parameters in typical REST API 336 | * @param {Object} clientConfig The "config" object for per-request config data. 337 | * @param {Function} callback callback convention is the same as Node.js 338 | * @static 339 | */ 340 | read: function (resource, params, clientConfig, callback) { 341 | var request = new Request('read', resource, this.options); 342 | if (1 === arguments.length) { 343 | return request; 344 | } 345 | // TODO: Remove below this line in release after next 346 | if (typeof clientConfig === 'function') { 347 | callback = clientConfig; 348 | clientConfig = {}; 349 | } 350 | return request 351 | .params(params) 352 | .clientConfig(clientConfig) 353 | .end(callback); 354 | }, 355 | 356 | /** 357 | * update operation (update as in CRUD). 358 | * @method update 359 | * @param {String} resource The resource name 360 | * @param {Object} params The parameters identify the resource, and along with information 361 | * carried in query and matrix parameters in typical REST API 362 | * @param {Object} body The JSON object that contains the resource data that is being updated 363 | * @param {Object} clientConfig The "config" object for per-request config data. 364 | * @param {Function} callback callback convention is the same as Node.js 365 | * @static 366 | */ 367 | update: function (resource, params, body, clientConfig, callback) { 368 | var request = new Request('update', resource, this.options); 369 | if (1 === arguments.length) { 370 | return request; 371 | } 372 | // TODO: Remove below this line in release after next 373 | if (typeof clientConfig === 'function') { 374 | callback = clientConfig; 375 | clientConfig = {}; 376 | } 377 | return request 378 | .params(params) 379 | .body(body) 380 | .clientConfig(clientConfig) 381 | .end(callback); 382 | }, 383 | 384 | /** 385 | * delete operation (delete as in CRUD). 386 | * @method delete 387 | * @param {String} resource The resource name 388 | * @param {Object} params The parameters identify the resource, and along with information 389 | * carried in query and matrix parameters in typical REST API 390 | * @param {Object} clientConfig The "config" object for per-request config data. 391 | * @param {Function} callback callback convention is the same as Node.js 392 | * @static 393 | */ 394 | 'delete': function (resource, params, clientConfig, callback) { 395 | var request = new Request('delete', resource, this.options); 396 | if (1 === arguments.length) { 397 | return request; 398 | } 399 | // TODO: Remove below this line in release after next 400 | if (typeof clientConfig === 'function') { 401 | callback = clientConfig; 402 | clientConfig = {}; 403 | } 404 | return request 405 | .params(params) 406 | .clientConfig(clientConfig) 407 | .end(callback); 408 | }, 409 | 410 | /** 411 | * Update options 412 | * @method updateOptions 413 | */ 414 | updateOptions: function (options) { 415 | this.options = lodash.merge(this.options, options); 416 | }, 417 | 418 | /** 419 | * get the serviceMeta array. 420 | * The array contains all xhr meta returned in this session 421 | * with the 0 index being the first call. 422 | * @method getServiceMeta 423 | * @return {Array} array of metadata returned by each service call 424 | */ 425 | getServiceMeta: function () { 426 | return this._serviceMeta; 427 | } 428 | }; 429 | 430 | module.exports = Fetcher; 431 | -------------------------------------------------------------------------------- /libs/fetcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 6 | var OP_READ = 'read'; 7 | var OP_CREATE = 'create'; 8 | var OP_UPDATE = 'update'; 9 | var GET = 'GET'; 10 | var qs = require('querystring'); 11 | var debug = require('debug')('Fetchr'); 12 | var fumble = require('fumble'); 13 | var objectAssign = require('object-assign'); 14 | var Promise = global.Promise || require('es6-promise').Promise; 15 | var RESOURCE_SANTIZER_REGEXP = /[^\w\.]+/g; 16 | 17 | function parseValue(value) { 18 | // take care of value of type: array, object 19 | try { 20 | var ret = JSON.parse(value); 21 | // Numbers larger than MAX_SAFE_INTEGER will contain rounding errors so, 22 | // we will just leave them as strings instead. The length > 15 check is because 23 | // the MAX_SAFE_INTEGER in javascript is 9007199254740991 which has a length of 16. 24 | // Using a length > 15 check is still safe and faster than trying to use another 25 | // library that supports big integers. 26 | if (typeof ret === 'number' && value.length > 15) { 27 | ret = value; 28 | } 29 | return ret; 30 | } catch (e) { 31 | return value; 32 | } 33 | } 34 | 35 | function parseParamValues (params) { 36 | return Object.keys(params).reduce(function (parsed, curr) { 37 | parsed[curr] = parseValue(params[curr]); 38 | return parsed; 39 | }, {}); 40 | } 41 | 42 | function sanitizeResourceName(resource) { 43 | return resource ? resource.replace(RESOURCE_SANTIZER_REGEXP, '*') : resource; 44 | } 45 | 46 | /** 47 | * Takes an error and resolves output and statusCode to respond to client with 48 | * 49 | * @param {Error} JavaScript error 50 | * @return {Object} object with resolved statusCode & output 51 | */ 52 | function getErrorResponse(err) { 53 | var statusCode = err.statusCode || 400; 54 | var output = { 55 | message: 'request failed' 56 | }; 57 | 58 | if (typeof err.output !== 'undefined') { 59 | output = err.output; 60 | } else if (err.message) { 61 | output.message = err.message; 62 | } 63 | 64 | return { 65 | statusCode: statusCode, 66 | output: output 67 | }; 68 | } 69 | 70 | 71 | /** 72 | * A Request instance represents a single fetcher request. 73 | * The constructor requires `operation` (CRUD) and `resource`. 74 | * @class Request 75 | * @param {String} operation The CRUD operation name: 'create|read|update|delete'. 76 | * @param {String} resource name of service 77 | * @param {Object} options configuration options for Request 78 | * @param {Object} [options.req] The request object from express/connect. It can contain per-request/context data. 79 | * @param {Array} [options.serviceMeta] Array to hold per-request/session metadata from all service calls. 80 | * @constructor 81 | */ 82 | function Request (operation, resource, options) { 83 | if (!resource) { 84 | throw new Error('Resource is required for a fetcher request'); 85 | } 86 | 87 | this.operation = operation || OP_READ; 88 | this.resource = resource; 89 | options = options || {}; 90 | this.req = options.req || {}; 91 | this.serviceMeta = options.serviceMeta || []; 92 | this._params = {}; 93 | this._body = null; 94 | this._clientConfig = {}; 95 | } 96 | 97 | /** 98 | * Add params to this fetcher request 99 | * @method params 100 | * @memberof Request 101 | * @param {Object} params Information carried in query and matrix parameters in typical REST API 102 | * @chainable 103 | */ 104 | Request.prototype.params = function (params) { 105 | this._params = params; 106 | return this; 107 | }; 108 | /** 109 | * Add body to this fetcher request 110 | * @method body 111 | * @memberof Request 112 | * @param {Object} body The JSON object that contains the resource data being updated for this request. 113 | * Not used for read and delete operations. 114 | * @chainable 115 | */ 116 | Request.prototype.body = function (body) { 117 | this._body = body; 118 | return this; 119 | }; 120 | /** 121 | * Add clientConfig to this fetcher request 122 | * @method config 123 | * @memberof Request 124 | * @param {Object} config config for this fetcher request 125 | * @chainable 126 | */ 127 | Request.prototype.clientConfig = function (config) { 128 | this._clientConfig = config; 129 | return this; 130 | }; 131 | 132 | /** 133 | * Execute this fetcher request and call callback. 134 | * @method end 135 | * @memberof Request 136 | * @param {Fetcher~fetcherCallback} callback callback invoked when service is complete. 137 | */ 138 | Request.prototype.end = function (callback) { 139 | var self = this; 140 | var promise = new Promise(function (resolve, reject) { 141 | setImmediate(executeRequest, self, resolve, reject); 142 | }); 143 | 144 | promise = promise.then(function (result) { 145 | if (result.meta) { 146 | self.serviceMeta.push(result.meta); 147 | } 148 | return result; 149 | }, function(errData) { 150 | if (errData.meta) { 151 | self.serviceMeta.push(errData.meta); 152 | } 153 | throw errData.err; 154 | }); 155 | 156 | if (callback) { 157 | promise.then(function (result) { 158 | setImmediate(callback, null, result.data, result.meta); 159 | }, function (err) { 160 | setImmediate(callback, err); 161 | }); 162 | } else { 163 | return promise; 164 | } 165 | }; 166 | 167 | /** 168 | * Execute and resolve/reject this fetcher request 169 | * @method executeRequest 170 | * @param {Object} request Request instance object 171 | * @param {Function} resolve function to call when request fulfilled 172 | * @param {Function} reject function to call when request rejected 173 | */ 174 | function executeRequest (request, resolve, reject) { 175 | var args = [request.req, request.resource, request._params, request._clientConfig, function executeRequestCallback(err, data, meta) { 176 | if (err) { 177 | reject({ 178 | err: err, 179 | meta: meta 180 | }); 181 | } else { 182 | resolve({ 183 | data: data, 184 | meta: meta 185 | }); 186 | } 187 | }]; 188 | var op = request.operation; 189 | if ((op === OP_CREATE) || (op === OP_UPDATE)) { 190 | args.splice(3, 0, request._body); 191 | } 192 | var service = Fetcher.getService(request.resource); 193 | service[op].apply(service, args); 194 | } 195 | 196 | /** 197 | * Fetcher class for the server. 198 | * Provides interface to register data services and 199 | * to later access those services. 200 | * @class Fetcher 201 | * @param {Object} options configuration options for Fetcher 202 | * @param {Object} [options.req] The express request object. It can contain per-request/context data. 203 | * @param {string} [options.xhrPath="/api"] The path for XHR requests. Will be ignored server side. 204 | * @constructor 205 | */ 206 | function Fetcher (options) { 207 | this.options = options || {}; 208 | this.req = this.options.req || {}; 209 | this.serviceMeta = []; 210 | 211 | } 212 | 213 | Fetcher.services = {}; 214 | 215 | /** 216 | * DEPRECATED 217 | * Register a data fetcher 218 | * @method registerFetcher 219 | * @memberof Fetcher 220 | * @param {Function} fetcher 221 | */ 222 | Fetcher.registerFetcher = function (fetcher) { 223 | // TODO: Uncomment warnings in next minor release 224 | // if ('production' !== process.env.NODE_ENV) { 225 | // console.warn('Fetcher.registerFetcher is deprecated. ' + 226 | // 'Please use Fetcher.registerService instead.'); 227 | // } 228 | return Fetcher.registerService(fetcher); 229 | }; 230 | 231 | /** 232 | * Register a data service 233 | * @method registerService 234 | * @memberof Fetcher 235 | * @param {Function} service 236 | */ 237 | Fetcher.registerService = function (fetcher) { 238 | if (!fetcher || !fetcher.name) { 239 | throw new Error('Service is not defined correctly'); 240 | } 241 | Fetcher.services[fetcher.name] = fetcher; 242 | debug('fetcher ' + fetcher.name + ' added'); 243 | return; 244 | }; 245 | 246 | /** 247 | * DEPRECATED 248 | * Retrieve a data fetcher by name 249 | * @method getFetcher 250 | * @memberof Fetcher 251 | * @param {String} name of fetcher 252 | * @returns {Function} fetcher 253 | */ 254 | Fetcher.getFetcher = function (name) { 255 | // TODO: Uncomment warnings in next minor release 256 | // if ('production' !== process.env.NODE_ENV) { 257 | // console.warn('Fetcher.getFetcher is deprecated. ' + 258 | // 'Please use Fetcher.getService instead.'); 259 | // } 260 | return Fetcher.getService(name); 261 | }; 262 | 263 | /** 264 | * Retrieve a data service by name 265 | * @method getService 266 | * @memberof Fetcher 267 | * @param {String} name of service 268 | * @returns {Function} service 269 | */ 270 | Fetcher.getService = function (name) { 271 | //Access service by name 272 | var service = Fetcher.isRegistered(name); 273 | if (!service) { 274 | throw new Error('Service "' + sanitizeResourceName(name) + '" could not be found'); 275 | } 276 | return service; 277 | }; 278 | 279 | /** 280 | * Returns true if service with name has been registered 281 | * @method isRegistered 282 | * @memberof Fetcher 283 | * @param {String} name of service 284 | * @returns {Boolean} true if service with name was registered 285 | */ 286 | Fetcher.isRegistered = function (name) { 287 | return name && Fetcher.services[name.split('.')[0]]; 288 | }; 289 | 290 | /** 291 | * Returns express/connect middleware for Fetcher 292 | * @method middleware 293 | * @memberof Fetcher 294 | * @param {Object} [options] Optional configurations 295 | * @param {Function} [options.responseFormatter=no op function] Function to modify the response 296 | before sending to client. First argument is the HTTP request object, 297 | second argument is the HTTP response object and the third argument is the service data object. 298 | * @returns {Function} middleware 299 | * @param {Object} req 300 | * @param {Object} res 301 | * @param {Object} next 302 | */ 303 | Fetcher.middleware = function (options) { 304 | options = options || {}; 305 | var responseFormatter = options.responseFormatter || function noOp(req, res, data) { 306 | return data; 307 | }; 308 | return function (req, res, next) { 309 | var request; 310 | var error; 311 | var serviceMeta; 312 | 313 | if (req.method === GET) { 314 | var path = req.path.substr(1).split(';'); 315 | var resource = path.shift(); 316 | 317 | if (!Fetcher.isRegistered(resource)) { 318 | error = fumble.http.badRequest('Invalid Fetchr Access', { 319 | debug: 'Bad resource ' + sanitizeResourceName(resource) 320 | }); 321 | error.source = 'fetchr'; 322 | return next(error); 323 | } 324 | serviceMeta = []; 325 | request = new Request(OP_READ, resource, { 326 | req: req, 327 | serviceMeta: serviceMeta 328 | }); 329 | request 330 | .params(parseParamValues(qs.parse(path.join('&')))) 331 | .end(function (err, data) { 332 | var meta = serviceMeta[0] || {}; 333 | if (meta.headers) { 334 | res.set(meta.headers); 335 | } 336 | if (err) { 337 | var errResponse = getErrorResponse(err); 338 | res.status(errResponse.statusCode).json(responseFormatter(req, res, errResponse.output)); 339 | return; 340 | } 341 | if (req.query.returnMeta) { 342 | res.status(meta.statusCode || 200).json(responseFormatter(req, res, { 343 | data: data, 344 | meta: meta 345 | })); 346 | } else { 347 | // TODO: Remove `returnMeta` feature flag after next release 348 | res.status(meta.statusCode || 200).json(data); 349 | } 350 | }); 351 | } else { 352 | var requests = req.body && req.body.requests; 353 | 354 | if (!requests || Object.keys(requests).length === 0) { 355 | error = fumble.http.badRequest('Invalid Fetchr Access', { 356 | debug: 'No resources' 357 | }); 358 | error.source = 'fetchr'; 359 | return next(error); 360 | } 361 | 362 | var DEFAULT_GUID = 'g0'; 363 | var singleRequest = requests[DEFAULT_GUID]; 364 | 365 | if (!Fetcher.isRegistered(singleRequest.resource)) { 366 | error = fumble.http.badRequest('Invalid Fetchr Access', { 367 | debug: 'Bad resource ' + sanitizeResourceName(singleRequest.resource) 368 | }); 369 | error.source = 'fetchr'; 370 | return next(error); 371 | } 372 | serviceMeta = []; 373 | request = new Request(singleRequest.operation, singleRequest.resource, { 374 | req: req, 375 | serviceMeta: serviceMeta 376 | }); 377 | request 378 | .params(singleRequest.params) 379 | .body(singleRequest.body || {}) 380 | .end(function(err, data) { 381 | var meta = serviceMeta[0] || {}; 382 | if (meta.headers) { 383 | res.set(meta.headers); 384 | } 385 | if (err) { 386 | var errResponse = getErrorResponse(err); 387 | res.status(errResponse.statusCode).json(responseFormatter(req, res, errResponse.output)); 388 | return; 389 | } 390 | var responseObj = {}; 391 | responseObj[DEFAULT_GUID] = responseFormatter(req, res, { 392 | data: data, 393 | meta: meta 394 | }); 395 | res.status(meta.statusCode || 200).json(responseObj); 396 | }); 397 | } 398 | // TODO: Batching and multi requests 399 | }; 400 | }; 401 | 402 | 403 | // ------------------------------------------------------------------ 404 | // CRUD Data Access Wrapper Methods 405 | // ------------------------------------------------------------------ 406 | 407 | /** 408 | * read operation (read as in CRUD). 409 | * @method read 410 | * @memberof Fetcher.prototype 411 | * @param {String} resource The resource name 412 | * @param {Object} params The parameters identify the resource, and along with information 413 | * carried in query and matrix parameters in typical REST API 414 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 415 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 416 | * @static 417 | */ 418 | Fetcher.prototype.read = function (resource, params, config, callback) { 419 | var request = new Request('read', resource, { 420 | req: this.req, 421 | serviceMeta: this.serviceMeta 422 | }); 423 | if (1 === arguments.length) { 424 | return request; 425 | } 426 | // TODO: Uncomment warnings in next minor release 427 | // if ('production' !== process.env.NODE_ENV) { 428 | // console.warn('The recommended way to use fetcher\'s .read method is \n' + 429 | // '.read(\'' + resource + '\').params({foo:bar}).end(callback);'); 430 | // } 431 | // TODO: Remove below this line in release after next 432 | if (typeof config === 'function') { 433 | callback = config; 434 | config = {}; 435 | } 436 | return request 437 | .params(params) 438 | .clientConfig(config) 439 | .end(callback); 440 | }; 441 | /** 442 | * create operation (create as in CRUD). 443 | * @method create 444 | * @memberof Fetcher.prototype 445 | * @param {String} resource The resource name 446 | * @param {Object} params The parameters identify the resource, and along with information 447 | * carried in query and matrix parameters in typical REST API 448 | * @param {Object} body The JSON object that contains the resource data that is being created 449 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 450 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 451 | * @static 452 | */ 453 | Fetcher.prototype.create = function (resource, params, body, config, callback) { 454 | var request = new Request('create', resource, { 455 | req: this.req, 456 | serviceMeta: this.serviceMeta 457 | }); 458 | if (1 === arguments.length) { 459 | return request; 460 | } 461 | // TODO: Uncomment warnings in next minor release 462 | // if ('production' !== process.env.NODE_ENV) { 463 | // console.warn('The recommended way to use fetcher\'s .create method is \n' + 464 | // '.create(\'' + resource + '\').params({foo:bar}).body({}).end(callback);'); 465 | // } 466 | // TODO: Remove below this line in release after next 467 | if (typeof config === 'function') { 468 | callback = config; 469 | config = {}; 470 | } 471 | return request 472 | .params(params) 473 | .body(body) 474 | .clientConfig(config) 475 | .end(callback); 476 | }; 477 | /** 478 | * update operation (update as in CRUD). 479 | * @method update 480 | * @memberof Fetcher.prototype 481 | * @param {String} resource The resource name 482 | * @param {Object} params The parameters identify the resource, and along with information 483 | * carried in query and matrix parameters in typical REST API 484 | * @param {Object} body The JSON object that contains the resource data that is being updated 485 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 486 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 487 | * @static 488 | */ 489 | Fetcher.prototype.update = function (resource, params, body, config, callback) { 490 | var request = new Request('update', resource, { 491 | req: this.req, 492 | serviceMeta: this.serviceMeta 493 | }); 494 | if (1 === arguments.length) { 495 | return request; 496 | } 497 | // TODO: Uncomment warnings in next minor release 498 | // if ('production' !== process.env.NODE_ENV) { 499 | // console.warn('The recommended way to use fetcher\'s .update method is \n' + 500 | // '.update(\'' + resource + '\').params({foo:bar}).body({}).end(callback);'); 501 | // } 502 | // TODO: Remove below this line in release after next 503 | if (typeof config === 'function') { 504 | callback = config; 505 | config = {}; 506 | } 507 | return request 508 | .params(params) 509 | .body(body) 510 | .clientConfig(config) 511 | .end(callback); 512 | }; 513 | /** 514 | * delete operation (delete as in CRUD). 515 | * @method delete 516 | * @memberof Fetcher.prototype 517 | * @param {String} resource The resource name 518 | * @param {Object} params The parameters identify the resource, and along with information 519 | * carried in query and matrix parameters in typical REST API 520 | * @param {Object} [config={}] The config object. It can contain "config" for per-request config data. 521 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher is complete. 522 | * @static 523 | */ 524 | Fetcher.prototype['delete'] = function (resource, params, config, callback) { 525 | var request = new Request('delete', resource, { 526 | req: this.req, 527 | serviceMeta: this.serviceMeta 528 | }); 529 | if (1 === arguments.length) { 530 | return request; 531 | } 532 | 533 | // TODO: Uncomment warnings in next minor release 534 | // if ('production' !== process.env.NODE_ENV) { 535 | // console.warn('The recommended way to use fetcher\'s .read method is \n' + 536 | // '.read(\'' + resource + '\').params({foo:bar}).end(callback);'); 537 | // } 538 | // TODO: Remove below this line in release after next 539 | if (typeof config === 'function') { 540 | callback = config; 541 | config = {}; 542 | } 543 | return request 544 | .params(params) 545 | .clientConfig(config) 546 | .end(callback); 547 | }; 548 | 549 | /** 550 | * update fetchr options 551 | * @method updateOptions 552 | * @memberof Fetcher.prototype 553 | * @param {Object} options configuration options for Fetcher 554 | * @param {Object} [options.req] The request object. It can contain per-request/context data. 555 | * @param {string} [options.xhrPath="/api"] The path for XHR requests. Will be ignored server side. 556 | */ 557 | Fetcher.prototype.updateOptions = function (options) { 558 | this.options = objectAssign(this.options, options); 559 | this.req = this.options.req || {}; 560 | }; 561 | 562 | /** 563 | * Get all the aggregated metadata sent data services in this request 564 | */ 565 | Fetcher.prototype.getServiceMeta = function () { 566 | return this.serviceMeta; 567 | }; 568 | 569 | module.exports = Fetcher; 570 | 571 | /** 572 | * @callback Fetcher~fetcherCallback 573 | * @param {Object} err The request error, pass null if there was no error. The data and meta parameters will be ignored if this parameter is not null. 574 | * @param {number} [err.statusCode=400] http status code to return 575 | * @param {string} [err.message=request failed] http response body 576 | * @param {Object} data request result 577 | * @param {Object} [meta] request meta-data 578 | * @param {number} [meta.statusCode=200] http status code to return 579 | */ 580 | -------------------------------------------------------------------------------- /tests/unit/libs/fetcher.client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*jshint expr:true*/ 6 | /*globals before,beforeEach,after,afterEach,describe,it */ 7 | "use strict"; 8 | 9 | var lodash = require('lodash'); 10 | var libUrl = require('url'); 11 | var expect = require('chai').expect; 12 | var mockery = require('mockery'); 13 | var Fetcher; 14 | var fetcher; 15 | var mockService = require('../../mock/MockService'); 16 | var app = require('../../mock/app'); 17 | var supertest = require('supertest'); 18 | var request = require('request'); 19 | var qs = require('qs'); 20 | var resource = 'mock_service'; 21 | var DEFAULT_XHR_PATH = '/api'; 22 | 23 | var validateGET; 24 | var validatePOST; 25 | var validateHTTP = function (options) { 26 | options = options || {}; 27 | validateGET = options.validateGET; 28 | validatePOST = options.validatePOST; 29 | }; 30 | describe('Client Fetcher', function () { 31 | beforeEach(function () { 32 | mockery.registerMock('./util/http.client', { 33 | get: function (url, headers, config, callback) { 34 | validateGET && validateGET(url, headers, config); 35 | supertest(app) 36 | .get(url) 37 | .expect(200) 38 | .end(function (err, res) { 39 | callback(err, { 40 | responseText: res.text 41 | }); 42 | }); 43 | }, 44 | post : function (url, headers, body, config, callback) { 45 | expect(url).to.not.be.empty; 46 | expect(callback).to.exist; 47 | expect(body).to.exist; 48 | validatePOST && validatePOST(url, headers, body, config); 49 | supertest(app) 50 | .post(url) 51 | .send(body) 52 | .expect(200) 53 | .end(function (err, res) { 54 | callback(err, { 55 | responseText: res.text 56 | }); 57 | }); 58 | } 59 | }); 60 | mockery.enable({ 61 | useCleanCache: true, 62 | warnOnUnregistered: false 63 | }); 64 | Fetcher = require('../../../libs/fetcher.client'); 65 | validateHTTP(); // Important, reset validate functions 66 | }); 67 | afterEach(function () { 68 | mockery.deregisterAll(); 69 | mockery.disable(); 70 | }); 71 | var testCrud = function (it, resource, params, body, config, callback) { 72 | it('should handle CREATE', function (done) { 73 | var operation = 'create'; 74 | fetcher 75 | [operation](resource) 76 | .params(params) 77 | .body(body) 78 | .clientConfig(config) 79 | .end(callback(operation, done)); 80 | }); 81 | it('should handle READ', function (done) { 82 | var operation = 'read'; 83 | fetcher 84 | [operation](resource) 85 | .params(params) 86 | .clientConfig(config) 87 | .end(callback(operation, done)); 88 | }); 89 | it('should handle UPDATE', function (done) { 90 | var operation = 'update'; 91 | fetcher 92 | [operation](resource) 93 | .params(params) 94 | .body(body) 95 | .clientConfig(config) 96 | .end(callback(operation, done)); 97 | }); 98 | it('should handle DELETE', function (done) { 99 | var operation = 'delete'; 100 | fetcher 101 | [operation](resource) 102 | .params(params) 103 | .clientConfig(config) 104 | .end(callback(operation, done)); 105 | }); 106 | }; 107 | 108 | describe('CRUD Interface', function () { 109 | beforeEach(function () { 110 | var context = {_csrf: 'stuff'}; 111 | fetcher = new Fetcher({ 112 | context: context 113 | }); 114 | validateHTTP({ 115 | validateGET: function (url, headers, config) { 116 | expect(url).to.contain(DEFAULT_XHR_PATH + '/' + resource); 117 | expect(url).to.contain('?_csrf=' + context._csrf); 118 | expect(url).to.contain('returnMeta=true'); 119 | }, 120 | validatePOST: function (url, headers, body, config) { 121 | expect(url).to.equal(DEFAULT_XHR_PATH + '?_csrf=' + context._csrf); 122 | } 123 | }); 124 | }); 125 | var params = { 126 | uuids: ['1','2','3','4','5'], 127 | meta: { 128 | headers: { 129 | 'x-foo-bar': 'foobar' 130 | } 131 | }, 132 | missing: undefined 133 | }; 134 | var body = { stuff: 'is'}; 135 | var config = {}; 136 | var callback = function (operation, done) { 137 | return function (err, data, meta) { 138 | if (err){ 139 | done(err); 140 | } 141 | expect(data.operation).to.exist; 142 | expect(data.operation.name).to.equal(operation); 143 | expect(data.operation.success).to.be.true; 144 | expect(data.args).to.exist; 145 | expect(data.args.resource).to.equal(resource); 146 | expect(data.args.params).to.eql(lodash.omitBy(params, lodash.isUndefined)); 147 | expect(meta).to.eql(params.meta); 148 | done(); 149 | }; 150 | }; 151 | var resolve = function (operation, done) { 152 | return function (result) { 153 | try { 154 | expect(result).to.exist; 155 | expect(result).to.have.keys('data', 'meta'); 156 | expect(result.data.operation).to.exist; 157 | expect(result.data.operation.name).to.equal(operation); 158 | expect(result.data.operation.success).to.be.true; 159 | expect(result.data.args).to.exist; 160 | expect(result.data.args.resource).to.equal(resource); 161 | expect(result.data.args.params).to.eql(lodash.omitBy(params, lodash.isUndefined)); 162 | expect(result.meta).to.eql(params.meta); 163 | } catch (e) { 164 | done(e); 165 | return; 166 | } 167 | done(); 168 | }; 169 | }; 170 | var reject = function (operation, done) { 171 | return function (err) { 172 | done(err); 173 | }; 174 | }; 175 | it('should keep track of session\'s metadata in getServiceMeta', function (done) { 176 | mockService.meta = { 177 | headers: { 178 | 'x-foo': 'foo' 179 | } 180 | }; 181 | fetcher 182 | .read(resource) 183 | .params(params) 184 | .end(function (err, data, meta) { 185 | if (err) { 186 | done(err); 187 | } 188 | expect(meta).to.eql({ 189 | headers: { 190 | 'x-foo': 'foo' 191 | } 192 | }); 193 | mockService.meta = { 194 | headers: { 195 | 'x-bar': 'bar' 196 | } 197 | }; 198 | fetcher 199 | .read(resource) 200 | .params(params) 201 | .end(function (err, data, meta) { 202 | if (err) { 203 | done(err); 204 | } 205 | expect(meta).to.eql({ 206 | headers: { 207 | 'x-bar': 'bar' 208 | } 209 | }); 210 | var serviceMeta = fetcher.getServiceMeta(); 211 | expect(serviceMeta).to.have.length(2); 212 | expect(serviceMeta[0].headers).to.eql({'x-foo': 'foo'}); 213 | expect(serviceMeta[1].headers).to.eql({'x-bar': 'bar'}); 214 | done(); 215 | }); 216 | }); 217 | }); 218 | describe('should work superagent style', function () { 219 | describe('with callbacks', function () { 220 | testCrud(it, resource, params, body, config, callback); 221 | it('should throw if no resource is given', function () { 222 | expect(fetcher.read.bind(fetcher)).to.throw('Resource is required for a fetcher request'); 223 | }); 224 | }); 225 | describe('with Promises', function () { 226 | it('should handle CREATE', function (done) { 227 | var operation = 'create'; 228 | fetcher 229 | [operation](resource) 230 | .params(params) 231 | .body(body) 232 | .clientConfig(config) 233 | .end() 234 | .then(resolve(operation, done), reject(operation, done)); 235 | }); 236 | it('should handle READ', function (done) { 237 | var operation = 'read'; 238 | fetcher 239 | [operation](resource) 240 | .params(params) 241 | .clientConfig(config) 242 | .end() 243 | .then(resolve(operation, done), reject(operation, done)); 244 | }); 245 | it('should handle UPDATE', function (done) { 246 | var operation = 'update'; 247 | fetcher 248 | [operation](resource) 249 | .params(params) 250 | .body(body) 251 | .clientConfig(config) 252 | .end() 253 | .then(resolve(operation, done), reject(operation, done)); 254 | }); 255 | it('should handle DELETE', function (done) { 256 | var operation = 'delete'; 257 | fetcher 258 | [operation](resource) 259 | .params(params) 260 | .clientConfig(config) 261 | .end() 262 | .then(resolve(operation, done), reject(operation, done)); 263 | }); 264 | it('should throw if no resource is given', function () { 265 | expect(fetcher.read).to.throw('Resource is required for a fetcher request'); 266 | }); 267 | }); 268 | }); 269 | describe('should be backwards compatible', function (done) { 270 | // with config 271 | it('should handle CREATE', function (done) { 272 | var operation = 'create'; 273 | fetcher[operation](resource, params, body, config, callback(operation, done)); 274 | }); 275 | it('should handle READ', function (done) { 276 | var operation = 'read'; 277 | fetcher[operation](resource, params, config, callback(operation, done)); 278 | }); 279 | it('should handle UPDATE', function (done) { 280 | var operation = 'update'; 281 | fetcher[operation](resource, params, body, config, callback(operation, done)); 282 | }); 283 | it('should handle DELETE', function (done) { 284 | var operation = 'delete'; 285 | fetcher[operation](resource, params, config, callback(operation, done)); 286 | }); 287 | 288 | // without config 289 | it('should handle CREATE w/ no config', function (done) { 290 | var operation = 'create'; 291 | fetcher[operation](resource, params, body, callback(operation, done)); 292 | }); 293 | it('should handle READ w/ no config', function (done) { 294 | var operation = 'read'; 295 | fetcher[operation](resource, params, callback(operation, done)); 296 | }); 297 | it('should handle UPDATE w/ no config', function (done) { 298 | var operation = 'update'; 299 | fetcher[operation](resource, params, body, callback(operation, done)); 300 | }); 301 | it('should handle DELETE w/ no config', function (done) { 302 | var operation = 'delete'; 303 | fetcher[operation](resource, params, callback(operation, done)); 304 | }); 305 | }); 306 | 307 | }); 308 | describe('CORS', function () { 309 | // start CORS app at localhost:3001 310 | var corsApp = require('../../mock/corsApp'); 311 | var corsPath = 'http://localhost:3001'; 312 | var params = { 313 | uuids: ['1','2','3','4','5'], 314 | corsDomain: 'test1' 315 | }, 316 | body = { stuff: 'is'}, 317 | context = { 318 | _csrf: 'stuff' 319 | }, 320 | callback = function(operation, done) { 321 | return function(err, data) { 322 | if (err){ 323 | return done(err); 324 | } 325 | if (data) { 326 | expect(data).to.deep.equal(params); 327 | } 328 | done(); 329 | }; 330 | }; 331 | beforeEach(function() { 332 | mockery.deregisterAll(); // deregister default http.client mock 333 | mockery.registerMock('./util/http.client', { // register CORS http.client mock 334 | get: function (url, headers, config, callback) { 335 | expect(url).to.contain(corsPath); 336 | var path = url.substr(corsPath.length); 337 | // constructGetUri above doesn't implement csrf so we don't check csrf here 338 | supertest(corsPath) 339 | .get(path) 340 | .expect(200) 341 | .end(function (err, res) { 342 | callback(err, { 343 | responseText: res.text 344 | }); 345 | }); 346 | }, 347 | post : function (url, headers, body, config, callback) { 348 | expect(url).to.not.be.empty; 349 | expect(callback).to.exist; 350 | expect(body).to.exist; 351 | expect(url).to.equal(corsPath + '?_csrf=' + context._csrf); 352 | var path = url.substring(corsPath.length); 353 | supertest(corsPath) 354 | .post(path) 355 | .send(body) 356 | .expect(200) 357 | .end(function (err, res) { 358 | callback(err, { 359 | responseText: res.text 360 | }); 361 | }); 362 | } 363 | }); 364 | mockery.resetCache(); 365 | Fetcher = require('../../../libs/fetcher.client'); 366 | fetcher = new Fetcher({ 367 | context: context, 368 | corsPath: corsPath 369 | }); 370 | }); 371 | afterEach(function () { 372 | mockery.deregisterAll(); // deregister CORS http.client mock 373 | }); 374 | 375 | 376 | function constructGetUri (uri, resource, params, config) { 377 | if (config.cors) { 378 | return uri + '/' + resource + '?' + qs.stringify(params, { arrayFormat: 'repeat' }); 379 | } 380 | } 381 | 382 | testCrud(it, resource, params, body, { 383 | cors: true, 384 | constructGetUri: constructGetUri 385 | }, callback); 386 | }); 387 | 388 | describe('xhrTimeout', function () { 389 | var DEFAULT_XHR_TIMEOUT = 3000; 390 | var params = { 391 | uuids: [1,2,3,4,5], 392 | category: '' 393 | }, 394 | body = { stuff: 'is'}, 395 | context = { 396 | _csrf: 'stuff' 397 | }, 398 | config = {}, 399 | callback = function(operation, done) { 400 | return function(err, data) { 401 | if (err){ 402 | done(err); 403 | } 404 | done(); 405 | }; 406 | }; 407 | 408 | describe('should be configurable globally', function () { 409 | beforeEach(function(){ 410 | validateHTTP({ 411 | validateGET: function (url, headers, config) { 412 | expect(config.xhrTimeout).to.equal(4000); 413 | }, 414 | validatePOST: function (url, headers, body, config) { 415 | expect(config.xhrTimeout).to.equal(4000); 416 | } 417 | }); 418 | 419 | fetcher = new Fetcher({ 420 | context: context, 421 | xhrTimeout: 4000 422 | }); 423 | }); 424 | 425 | testCrud(it, resource, params, body, config, callback); 426 | }); 427 | 428 | describe('should be configurable per each fetchr call', function () { 429 | config = {timeout: 5000}; 430 | beforeEach(function(){ 431 | validateHTTP({ 432 | validateGET: function (url, headers, config) { 433 | expect(config.xhrTimeout).to.equal(4000); 434 | expect(config.timeout).to.equal(5000); 435 | }, 436 | validatePOST: function (url, headers, body, config) { 437 | expect(config.xhrTimeout).to.equal(4000); 438 | expect(config.timeout).to.equal(5000); 439 | } 440 | }); 441 | fetcher = new Fetcher({ 442 | context: context, 443 | xhrTimeout: 4000 444 | }); 445 | }); 446 | 447 | testCrud(it, resource, params, body, config, callback); 448 | }); 449 | 450 | describe('should default to DEFAULT_XHR_TIMEOUT of 3000', function () { 451 | beforeEach(function(){ 452 | validateHTTP({ 453 | validateGET: function (url, headers, config) { 454 | expect(config.xhrTimeout).to.equal(DEFAULT_XHR_TIMEOUT); 455 | }, 456 | validatePOST: function (url, headers, body, config) { 457 | expect(config.xhrTimeout).to.equal(DEFAULT_XHR_TIMEOUT); 458 | } 459 | }); 460 | 461 | fetcher = new Fetcher({ 462 | context: context 463 | }); 464 | }); 465 | 466 | testCrud(it, resource, params, body, config, callback); 467 | }); 468 | }); 469 | 470 | describe('Context Picker', function () { 471 | var context = {_csrf: 'stuff', random: 'randomnumber'}; 472 | var params = {}; 473 | var body = {}; 474 | var config = {}; 475 | var callback = function(operation, done) { 476 | return function(err, data) { 477 | if (err){ 478 | done(err); 479 | } 480 | done(); 481 | }; 482 | }; 483 | 484 | function prepTest (fetcherInstance) { 485 | fetcher = fetcherInstance; 486 | validateHTTP({ 487 | validateGET: function (url, headers, config) { 488 | expect(url).to.contain(DEFAULT_XHR_PATH + '/' + resource); 489 | expect(url).to.contain('?_csrf=' + context._csrf); 490 | // for GET, ignore 'random' 491 | expect(url).to.not.contain('random=' + context.random); 492 | expect(url).to.contain('returnMeta=true'); 493 | }, 494 | validatePOST: function (url, headers, body, config) { 495 | expect(url).to.equal(DEFAULT_XHR_PATH + '?_csrf=' + 496 | context._csrf + '&random=' + context.random); 497 | } 498 | }); 499 | } 500 | 501 | describe('Function', function () { 502 | beforeEach(function () { 503 | prepTest(new Fetcher({ 504 | context: context, 505 | contextPicker: { 506 | GET: function getContextPicker(value, key) { 507 | if (key === 'random') { 508 | return false; 509 | } 510 | return true; 511 | } 512 | } 513 | })); 514 | }); 515 | 516 | testCrud(it, resource, params, body, config, callback); 517 | }); 518 | 519 | describe('Property Name', function () { 520 | beforeEach(function () { 521 | prepTest(new Fetcher({ 522 | context: context, 523 | contextPicker: { 524 | GET: '_csrf' 525 | } 526 | })); 527 | }); 528 | 529 | testCrud(it, resource, params, body, config, callback); 530 | }); 531 | 532 | describe('Property Names', function () { 533 | beforeEach(function () { 534 | prepTest(new Fetcher({ 535 | context: context, 536 | contextPicker: { 537 | GET: ['_csrf'] 538 | } 539 | })); 540 | }); 541 | 542 | testCrud(it, resource, params, body, config, callback); 543 | }); 544 | }); 545 | 546 | 547 | describe('Utils', function () { 548 | it('should able to update options', function () { 549 | fetcher = new Fetcher({ 550 | context: { 551 | _csrf: 'stuff' 552 | }, 553 | xhrTimeout: 1000 554 | }); 555 | fetcher.updateOptions({ 556 | context: { 557 | lang : 'en-US', 558 | }, 559 | xhrTimeout: 1500 560 | }); 561 | expect(fetcher.options.xhrTimeout).to.equal(1500); 562 | // new context should be merged 563 | expect(fetcher.options.context._csrf).to.equal('stuff'); 564 | expect(fetcher.options.context.lang).to.equal('en-US'); 565 | }); 566 | }); 567 | }); 568 | -------------------------------------------------------------------------------- /tests/unit/libs/fetcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*jshint expr:true*/ 6 | /*globals before,describe,it */ 7 | "use strict"; 8 | 9 | var chai = require('chai'); 10 | chai.config.includeStack = true; 11 | var expect = chai.expect; 12 | var Fetcher = require('../../../libs/fetcher'); 13 | var fetcher; 14 | var mockService = require('../../mock/MockService'); 15 | var mockErrorService = require('../../mock/MockErrorService'); 16 | var qs = require('querystring'); 17 | 18 | describe('Server Fetcher', function () { 19 | beforeEach(function () { 20 | Fetcher.registerService(mockService); 21 | Fetcher.registerService(mockErrorService); 22 | }); 23 | afterEach(function () { 24 | Fetcher.services = {}; // reset services 25 | }); 26 | it('should register valid services', function () { 27 | Fetcher.services = {}; // reset services so we can test getService and registerService methods 28 | var getService = Fetcher.getService.bind(Fetcher); 29 | expect(getService).to.throw(Error, 'Service "undefined" could not be found'); 30 | getService = Fetcher.getService.bind(Fetcher, mockService.name); 31 | expect(getService).to.throw(Error, 'Service "' + mockService.name + '" could not be found'); 32 | expect(Object.keys(Fetcher.services)).to.have.length(0); 33 | Fetcher.registerService(mockService); 34 | expect(Object.keys(Fetcher.services)).to.have.length(1); 35 | expect(getService()).to.deep.equal(mockService); 36 | Fetcher.registerService(mockErrorService); 37 | expect(Object.keys(Fetcher.services)).to.have.length(2); 38 | 39 | // valid vs invalid 40 | var invalidService = {not_name: 'test_name'}; 41 | var validService = {name: 'test_name'}; 42 | var registerInvalidService = Fetcher.registerService.bind(Fetcher, undefined); 43 | expect(registerInvalidService).to.throw(Error, 'Service is not defined correctly'); 44 | registerInvalidService = Fetcher.registerService.bind(Fetcher, invalidService); 45 | expect(registerInvalidService).to.throw(Error, 'Service is not defined correctly'); 46 | var registerValidService = Fetcher.registerService.bind(Fetcher, validService); 47 | expect(registerValidService).to.not.throw; 48 | }); 49 | 50 | it('should get services by resource and sub resource', function () { 51 | var getService = Fetcher.getService.bind(Fetcher, mockService.name); 52 | expect(getService).to.not.throw; 53 | expect(getService()).to.deep.equal(mockService); 54 | getService = Fetcher.getService.bind(Fetcher, mockService.name + '.subResource'); 55 | expect(getService).to.not.throw; 56 | expect(getService()).to.deep.equal(mockService); 57 | }); 58 | 59 | it('should be able to update options for the fetchr instance', function () { 60 | fetcher = new Fetcher({ req: {} }); 61 | expect(fetcher.options.req.foo).to.be.undefined; 62 | fetcher.updateOptions({req: {foo: 'bar'}}); 63 | expect(fetcher.options.req.foo).to.equal('bar'); 64 | fetcher = null; 65 | }); 66 | 67 | describe('should be backwards compatible', function () { 68 | it('#registerFetcher & #getFetcher', function () { 69 | Fetcher.services = {}; // reset services so we can test getFetcher and registerFetcher methods 70 | var getFetcher = Fetcher.getFetcher.bind(Fetcher, mockService.name); 71 | expect(getFetcher).to.throw; 72 | Fetcher.registerFetcher(mockService); 73 | expect(getFetcher).to.not.throw; 74 | expect(getFetcher()).to.deep.equal(mockService); 75 | getFetcher = Fetcher.getFetcher.bind(Fetcher, mockService.name + '.subResource'); 76 | expect(getFetcher).to.not.throw; 77 | expect(getFetcher()).to.deep.equal(mockService); 78 | }); 79 | }); 80 | 81 | 82 | describe('#middleware', function () { 83 | describe('#POST', function() { 84 | it('should respond to POST api request', function (done) { 85 | var operation = 'create', 86 | statusCodeSet = false, 87 | req = { 88 | method: 'POST', 89 | path: '/' + mockService.name, 90 | body: { 91 | requests: { 92 | g0: { 93 | resource: mockService.name, 94 | operation: operation, 95 | params: { 96 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 97 | id: 'asdf' 98 | } 99 | } 100 | }, 101 | context: { 102 | site: '', 103 | device: '' 104 | } 105 | } 106 | }, 107 | res = { 108 | json: function(response) { 109 | expect(response).to.exist; 110 | expect(response).to.not.be.empty; 111 | var data = response.g0.data; 112 | expect(data).to.contain.keys('operation', 'args'); 113 | expect(data.operation.name).to.equal(operation); 114 | expect(data.operation.success).to.be.true; 115 | expect(data.args).to.contain.keys('params'); 116 | expect(data.args.params).to.equal(req.body.requests.g0.params); 117 | expect(statusCodeSet).to.be.true; 118 | done(); 119 | }, 120 | status: function(code) { 121 | expect(code).to.equal(200); 122 | statusCodeSet = true; 123 | return this; 124 | }, 125 | send: function (code) { 126 | console.log('Not Expected: middleware responded with', code); 127 | } 128 | }, 129 | next = function () { 130 | console.log('Not Expected: middleware skipped request'); 131 | }, 132 | middleware = Fetcher.middleware(); 133 | 134 | middleware(req, res, next); 135 | }); 136 | 137 | it('should respond to POST api request with custom status code and custom headers', function (done) { 138 | var operation = 'create', 139 | statusCode = 201, 140 | statusCodeSet = false, 141 | responseHeaders = {'x-foo': 'foo'}, 142 | headersSet, 143 | req = { 144 | method: 'POST', 145 | path: '/' + mockService.name, 146 | body: { 147 | requests: { 148 | g0: { 149 | resource: mockService.name, 150 | operation: operation, 151 | params: { 152 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 153 | id: 'asdf' 154 | } 155 | } 156 | }, 157 | context: { 158 | site: '', 159 | device: '' 160 | } 161 | } 162 | }, 163 | res = { 164 | json: function(response) { 165 | expect(response).to.exist; 166 | expect(response).to.not.be.empty; 167 | var data = response.g0.data; 168 | expect(data).to.contain.keys('operation', 'args'); 169 | expect(data.operation.name).to.equal(operation); 170 | expect(data.operation.success).to.be.true; 171 | expect(data.args).to.contain.keys('params'); 172 | expect(data.args.params).to.equal(req.body.requests.g0.params); 173 | expect(headersSet).to.eql(responseHeaders); 174 | expect(statusCodeSet).to.be.true; 175 | done(); 176 | }, 177 | status: function(code) { 178 | expect(code).to.equal(statusCode); 179 | statusCodeSet = true; 180 | return this; 181 | }, 182 | set: function(headers) { 183 | headersSet = headers; 184 | return this; 185 | }, 186 | send: function (code) { 187 | console.log('Not Expected: middleware responded with', code); 188 | } 189 | }, 190 | next = function () { 191 | console.log('Not Expected: middleware skipped request'); 192 | }, 193 | middleware = Fetcher.middleware({pathPrefix: '/api'}); 194 | 195 | mockService.meta = { 196 | headers: responseHeaders, 197 | statusCode: statusCode 198 | }; 199 | 200 | middleware(req, res, next); 201 | }); 202 | 203 | var makePostApiErrorTest = function(params, expStatusCode, expMessage) { 204 | return function(done) { 205 | var operation = 'create', 206 | statusCodeSet = false, 207 | req = { 208 | method: 'POST', 209 | path: '/' + mockErrorService.name, 210 | body: { 211 | requests: { 212 | g0: { 213 | resource: mockErrorService.name, 214 | operation: operation, 215 | params: params 216 | } 217 | }, 218 | context: { 219 | site: '', 220 | device: '' 221 | } 222 | } 223 | }, 224 | res = { 225 | json: function(data) { 226 | expect(data).to.eql(expMessage); 227 | expect(statusCodeSet).to.be.true; 228 | done(); 229 | }, 230 | status: function(code) { 231 | expect(code).to.equal(expStatusCode); 232 | statusCodeSet = true; 233 | return this; 234 | }, 235 | send: function (data) { 236 | console.log('send() not expected: middleware responded with', data); 237 | } 238 | }, 239 | next = function () { 240 | console.log('next() not expected: middleware skipped request'); 241 | }, 242 | middleware = Fetcher.middleware({pathPrefix: '/api'}); 243 | middleware(req, res, next); 244 | }; 245 | }; 246 | 247 | it('should respond to POST api request with default error details', 248 | makePostApiErrorTest({}, 400, {message: 'request failed'})); 249 | 250 | it('should respond to POST api request with custom error status code', 251 | makePostApiErrorTest({statusCode: 500}, 500, {message: 'request failed'})); 252 | 253 | it('should respond to POST api request with custom error message', 254 | makePostApiErrorTest({message: 'Error message...'}, 400, {message: 'Error message...'})); 255 | 256 | it('should respond to POST api request with no leaked error information', 257 | makePostApiErrorTest({statusCode: 500, danger: 'zone'}, 500, {message: 'request failed'})); 258 | 259 | 260 | describe('should respond to POST api request with custom output', function() { 261 | it('using json object', 262 | makePostApiErrorTest({statusCode: 500, output: { 263 | message: 'custom message', 264 | foo : 'bar', 265 | }}, 500, {message: 'custom message', 'foo': 'bar'})); 266 | 267 | it('using json array', 268 | makePostApiErrorTest({statusCode: 500, output: [1, 2]}, 500, [1, 2])); 269 | }); 270 | }); 271 | 272 | 273 | describe('#GET', function() { 274 | it('should respond to GET api request w/o meta', function (done) { 275 | var operation = 'read'; 276 | var statusCodeSet = false; 277 | var params = { 278 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 279 | id: 'asdf' 280 | }; 281 | var req = { 282 | method: 'GET', 283 | path: '/' + mockService.name + ';' + qs.stringify(params, ';'), 284 | query: {} 285 | }; 286 | var res = { 287 | json: function(response) { 288 | expect(response).to.exist; 289 | expect(response).to.not.be.empty; 290 | expect(response).to.not.contain.keys('data', 'meta'); 291 | expect(response).to.contain.keys('operation', 'args'); 292 | expect(response.operation.name).to.equal(operation); 293 | expect(response.operation.success).to.be.true; 294 | expect(response.args).to.contain.keys('params'); 295 | expect(response.args.params).to.deep.equal(params); 296 | expect(statusCodeSet).to.be.true; 297 | done(); 298 | }, 299 | status: function(code) { 300 | expect(code).to.equal(200); 301 | statusCodeSet = true; 302 | return this; 303 | }, 304 | send: function (code) { 305 | console.log('Not Expected: middleware responded with', code); 306 | } 307 | }; 308 | var next = function () { 309 | console.log('Not Expected: middleware skipped request'); 310 | }; 311 | var middleware = Fetcher.middleware({pathPrefix: '/api'}); 312 | 313 | middleware(req, res, next); 314 | }); 315 | it('should respond to GET api request', function (done) { 316 | var operation = 'read', 317 | statusCodeSet = false, 318 | params = { 319 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 320 | id: 'asdf', 321 | }, 322 | req = { 323 | method: 'GET', 324 | path: '/' + mockService.name + ';' + qs.stringify(params, ';'), 325 | query: { 326 | returnMeta: true 327 | } 328 | }, 329 | res = { 330 | json: function(response) { 331 | expect(response).to.exist; 332 | expect(response).to.not.be.empty; 333 | expect(response).to.contain.keys('data', 'meta'); 334 | expect(response.data).to.contain.keys('operation', 'args'); 335 | expect(response.data.operation.name).to.equal(operation); 336 | expect(response.data.operation.success).to.be.true; 337 | expect(response.data.args).to.contain.keys('params'); 338 | expect(response.data.args.params).to.deep.equal(params); 339 | expect(response.meta).to.be.empty; 340 | expect(statusCodeSet).to.be.true; 341 | done(); 342 | }, 343 | status: function(code) { 344 | expect(code).to.equal(200); 345 | statusCodeSet = true; 346 | return this; 347 | }, 348 | send: function (code) { 349 | console.log('Not Expected: middleware responded with', code); 350 | } 351 | }, 352 | next = function () { 353 | console.log('Not Expected: middleware skipped request'); 354 | }, 355 | middleware = Fetcher.middleware({pathPrefix: '/api'}); 356 | 357 | middleware(req, res, next); 358 | }); 359 | 360 | it('should respond to GET api request with custom status code and custom headers', function (done) { 361 | var operation = 'read', 362 | statusCodeSet = false, 363 | statusCode = 201, 364 | responseHeaders = {'Cache-Control': 'max-age=300'}, 365 | headersSet, 366 | params = { 367 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 368 | id: 'asdf', 369 | }, 370 | req = { 371 | method: 'GET', 372 | path: '/' + mockService.name + ';' + qs.stringify(params, ';'), 373 | query: { 374 | returnMeta: true 375 | } 376 | }, 377 | res = { 378 | json: function(response) { 379 | expect(response).to.exist; 380 | expect(response).to.not.be.empty; 381 | expect(response).to.contain.keys('data', 'meta'); 382 | expect(response.data).to.contain.keys('operation', 'args'); 383 | expect(response.data.operation.name).to.equal(operation); 384 | expect(response.data.operation.success).to.be.true; 385 | expect(response.data.args).to.contain.keys('params'); 386 | expect(response.data.args.params).to.deep.equal(params); 387 | expect(response.meta).to.eql({ 388 | headers: responseHeaders, 389 | statusCode: statusCode 390 | }); 391 | expect(statusCodeSet).to.be.true; 392 | expect(headersSet).to.eql(responseHeaders); 393 | done(); 394 | }, 395 | set: function(headers) { 396 | headersSet = headers; 397 | return this; 398 | }, 399 | status: function(code) { 400 | expect(code).to.equal(statusCode); 401 | statusCodeSet = true; 402 | return this; 403 | }, 404 | send: function (code) { 405 | console.log('Not Expected: middleware responded with', code); 406 | } 407 | }, 408 | next = function () { 409 | console.log('Not Expected: middleware skipped request'); 410 | }, 411 | middleware = Fetcher.middleware({pathPrefix: '/api'}); 412 | 413 | mockService.meta = { 414 | headers: responseHeaders, 415 | statusCode: statusCode 416 | }; 417 | middleware(req, res, next); 418 | }); 419 | 420 | it('should leave big integers in query params as strings', function (done) { 421 | var operation = 'read', 422 | statusCodeSet = false, 423 | params = { 424 | id: '123456789012345', // will not cause rounding errors 425 | bigId: '1234567890123456789' // will cause rounding erros 426 | }, 427 | req = { 428 | method: 'GET', 429 | path: '/' + mockService.name + ';' + qs.stringify(params, ';'), 430 | query: { 431 | returnMeta: true 432 | } 433 | }, 434 | res = { 435 | json: function(response) { 436 | expect(response).to.exist; 437 | expect(response).to.not.be.empty; 438 | expect(response).to.contain.keys('data', 'meta'); 439 | expect(response.data).to.contain.keys('operation', 'args'); 440 | expect(response.data.operation.name).to.equal(operation); 441 | expect(response.data.operation.success).to.be.true; 442 | expect(response.data.args).to.contain.keys('params'); 443 | expect(response.data.args.params.id).to.be.a.number; 444 | expect(response.data.args.params.id.toString()).to.equal(params.id); 445 | expect(response.data.args.params.bigId).to.be.a.String; 446 | expect(response.data.args.params.bigId.toString()).to.equal(params.bigId); 447 | expect(statusCodeSet).to.be.true; 448 | done(); 449 | }, 450 | status: function(code) { 451 | expect(code).to.equal(200); 452 | statusCodeSet = true; 453 | return this; 454 | }, 455 | send: function (code) { 456 | console.log('Not Expected: middleware responded with', code); 457 | } 458 | }, 459 | next = function () { 460 | console.log('Not Expected: middleware skipped request'); 461 | }, 462 | middleware = Fetcher.middleware({pathPrefix: '/api'}); 463 | 464 | middleware(req, res, next); 465 | }); 466 | 467 | var paramsToQuerystring = function(params) { 468 | var str = ''; 469 | for (var key in params) { 470 | str += ';' + key + '=' + JSON.stringify(params[key]); 471 | } 472 | 473 | return str; 474 | }; 475 | 476 | var makeGetApiErrorTest = function(params, expStatusCode, expMessage) { 477 | return function(done) { 478 | var operation = 'read', 479 | statusCodeSet = false, 480 | 481 | req = { 482 | method: 'GET', 483 | path: '/' + mockErrorService.name + paramsToQuerystring(params), 484 | params: params, 485 | }, 486 | 487 | res = { 488 | json: function(data) { 489 | expect(data).to.eql(expMessage); 490 | expect(statusCodeSet).to.be.true; 491 | done(); 492 | }, 493 | status: function(code) { 494 | expect(code).to.equal(expStatusCode); 495 | statusCodeSet = true; 496 | return this; 497 | }, 498 | send: function (data) { 499 | console.log('send() not expected: middleware responded with', data); 500 | } 501 | }, 502 | next = function () { 503 | console.log('Not Expected: middleware skipped request'); 504 | }, 505 | middleware = Fetcher.middleware({pathPrefix: '/api'}); 506 | middleware(req, res, next); 507 | }; 508 | }; 509 | 510 | it('should respond to GET api request with default error details', 511 | makeGetApiErrorTest({}, 400, {message: 'request failed'})); 512 | 513 | it('should respond to GET api request with custom error status code', 514 | makeGetApiErrorTest({statusCode: 500}, 500, {message: 'request failed'})); 515 | 516 | it('should respond to GET api request with no leaked error information', 517 | makeGetApiErrorTest({statusCode: 500, danger: 'zone'}, 500, {message: 'request failed'})); 518 | 519 | it('should respond to GET api request with custom error message', 520 | makeGetApiErrorTest({message: 'Error message...'}, 400, {message: 'Error message...'})); 521 | 522 | describe('should respond to GET api request with custom output', function() { 523 | it('using json object', 524 | makeGetApiErrorTest({statusCode: 500, output: { 525 | message: 'custom message', 526 | foo : 'bar', 527 | }}, 500, {message: 'custom message', 'foo': 'bar'})); 528 | 529 | it('using json array', 530 | makeGetApiErrorTest({statusCode: 500, output: [1, 2]}, 500, [1, 2])); 531 | }); 532 | }); 533 | 534 | describe('Invalid Access', function () { 535 | function makeInvalidReqTest(req, debugMsg, done) { 536 | var res = {}; 537 | var next = function (err) { 538 | expect(err).to.exist; 539 | expect(err).to.be.an.object; 540 | expect(err.debug).to.contain(debugMsg); 541 | expect(err.message).to.equal('Invalid Fetchr Access'); 542 | expect(err.statusCode).to.equal(400); 543 | expect(err.source).to.equal('fetchr'); 544 | done(); 545 | }; 546 | var middleware = Fetcher.middleware(); 547 | middleware(req, res, next); 548 | } 549 | it('should skip empty url', function (done) { 550 | makeInvalidReqTest({method: 'GET', path: '/'}, 'Bad resource', done); 551 | }); 552 | it('should skip invalid GET resource', function (done) { 553 | makeInvalidReqTest({method: 'GET', path: '/invalidService'}, 'Bad resource invalidService', done); 554 | }); 555 | it('should sanitize resource name for invalid GET resource', function (done) { 556 | makeInvalidReqTest({method: 'GET', path: '/invalid&Service'}, 'Bad resource invalid*Service', done); 557 | }); 558 | it('should skip invalid POST request', function (done) { 559 | makeInvalidReqTest({method: 'POST', body: { 560 | requests: { 561 | g0: { 562 | resource: 'invalidService' 563 | } 564 | } 565 | }}, 'Bad resource invalidService', done); 566 | }); 567 | it('should sanitize invalid POST request', function (done) { 568 | makeInvalidReqTest({method: 'POST', body: { 569 | requests: { 570 | g0: { 571 | resource: 'invalid&Service' 572 | } 573 | } 574 | }}, 'Bad resource invalid*Service', done); 575 | }); 576 | it('should skip POST request with empty req.body.requests object', function (done) { 577 | makeInvalidReqTest({method: 'POST', body: { requests: {}}}, 'No resources', done); 578 | }); 579 | it('should skip POST request with no req.body.requests object', function (done) { 580 | makeInvalidReqTest({method: 'POST'}, 'No resources', done); 581 | }); 582 | 583 | }); 584 | 585 | describe('Response Formatter', function () { 586 | describe('GET', function () { 587 | it('should modify the response object', function (done) { 588 | var operation = 'read'; 589 | var statusCodeSet = false; 590 | var params = { 591 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 592 | id: 'asdf' 593 | }; 594 | var req = { 595 | method: 'GET', 596 | path: '/' + mockService.name + ';' + qs.stringify(params, ';'), 597 | query: { 598 | returnMeta: true 599 | } 600 | }; 601 | var res = { 602 | json: function(response) { 603 | expect(response).to.exist; 604 | expect(response).to.not.be.empty; 605 | expect(response).to.contain.keys('data', 'meta', 'modified'); 606 | expect(response.data).to.contain.keys('operation', 'args'); 607 | expect(response.data.operation.name).to.equal(operation); 608 | expect(response.data.operation.success).to.be.true; 609 | expect(response.data.args).to.contain.keys('params'); 610 | expect(response.data.args.params).to.deep.equal(params); 611 | expect(response.meta).to.be.empty; 612 | expect(statusCodeSet).to.be.true; 613 | done(); 614 | }, 615 | status: function(code) { 616 | expect(code).to.equal(200); 617 | statusCodeSet = true; 618 | return this; 619 | }, 620 | send: function (code) { 621 | console.log('Not Expected: middleware responded with', code); 622 | } 623 | }; 624 | var next = function () { 625 | console.log('Not Expected: middleware skipped request'); 626 | }; 627 | var middleware = Fetcher.middleware({responseFormatter: function (req, res, data) { 628 | data.modified = true; 629 | return data; 630 | }}); 631 | 632 | middleware(req, res, next); 633 | }); 634 | }); 635 | describe('POST', function () { 636 | it('should modify the response object', function (done) { 637 | var operation = 'create', 638 | statusCodeSet = false, 639 | req = { 640 | method: 'POST', 641 | path: '/' + mockService.name, 642 | body: { 643 | requests: { 644 | g0: { 645 | resource: mockService.name, 646 | operation: operation, 647 | params: { 648 | uuids: ['cd7240d6-aeed-3fed-b63c-d7e99e21ca17', 'cd7240d6-aeed-3fed-b63c-d7e99e21ca17'], 649 | id: 'asdf' 650 | } 651 | } 652 | }, 653 | context: { 654 | site: '', 655 | device: '' 656 | } 657 | } 658 | }, 659 | res = { 660 | json: function(response) { 661 | expect(response).to.exist; 662 | expect(response).to.not.be.empty; 663 | expect(response.g0).to.contain.keys('data', 'meta', 'modified'); 664 | var data = response.g0.data; 665 | expect(data).to.contain.keys('operation', 'args'); 666 | expect(data.operation.name).to.equal(operation); 667 | expect(data.operation.success).to.be.true; 668 | expect(data.args).to.contain.keys('params'); 669 | expect(data.args.params).to.equal(req.body.requests.g0.params); 670 | expect(statusCodeSet).to.be.true; 671 | done(); 672 | }, 673 | status: function(code) { 674 | expect(code).to.equal(200); 675 | statusCodeSet = true; 676 | return this; 677 | }, 678 | send: function (code) { 679 | console.log('Not Expected: middleware responded with', code); 680 | } 681 | }, 682 | next = function () { 683 | console.log('Not Expected: middleware skipped request'); 684 | }, 685 | middleware = Fetcher.middleware({responseFormatter: function (req, res, data) { 686 | data.modified = true; 687 | return data; 688 | }}); 689 | 690 | middleware(req, res, next); 691 | }); 692 | }); 693 | }); 694 | }); 695 | 696 | describe('CRUD Interface', function () { 697 | var resource = mockService.name; 698 | var params = {}; 699 | var body = {}; 700 | var config = {}; 701 | var callback = function(operation, done) { 702 | return function(err, data) { 703 | if (err){ 704 | done(err); 705 | } 706 | expect(data.operation).to.exist; 707 | expect(data.operation.name).to.equal(operation); 708 | expect(data.operation.success).to.be.true; 709 | done(); 710 | }; 711 | }; 712 | var resolve = function (operation, done) { 713 | return function (result) { 714 | try { 715 | expect(result.data).to.exist; 716 | expect(result.data.operation).to.exist; 717 | expect(result.data.operation.name).to.equal(operation); 718 | expect(result.data.operation.success).to.be.true; 719 | } catch (e) { 720 | done(e); 721 | return; 722 | } 723 | done(); 724 | }; 725 | }; 726 | var reject = function (operation, done) { 727 | return function (err) { 728 | done(err); 729 | }; 730 | }; 731 | beforeEach(function () { 732 | fetcher = new Fetcher ({ 733 | req: {} 734 | }); 735 | }); 736 | it('should keep track of serviceMeta data', function (done) { 737 | var fetcher = new Fetcher ({ 738 | req: {} 739 | }); 740 | mockService.meta = { 741 | headers: { 742 | 'x-foo': 'foo' 743 | } 744 | }; 745 | fetcher 746 | .read(resource) 747 | .params(params) 748 | .end(function (err) { 749 | if (err) { 750 | done(err); 751 | } 752 | mockService.meta = { 753 | headers: { 754 | 'x-bar': 'bar' 755 | } 756 | }; 757 | fetcher 758 | .read(resource) 759 | .params(params) 760 | .end(function (err) { 761 | if (err) { 762 | done(err); 763 | } 764 | var serviceMeta = fetcher.getServiceMeta(); 765 | expect(serviceMeta).to.have.length(2); 766 | expect(serviceMeta[0].headers).to.eql({'x-foo': 'foo'}) 767 | expect(serviceMeta[1].headers).to.eql({'x-bar': 'bar'}) 768 | done(); 769 | }); 770 | }); 771 | }); 772 | it('should have serviceMeta data on error', function (done) { 773 | var fetcher = new Fetcher ({ 774 | req: {} 775 | }); 776 | mockErrorService.meta = { 777 | headers: { 778 | 'x-foo': 'foo' 779 | } 780 | }; 781 | fetcher 782 | .read(mockErrorService.name) 783 | .params(params) 784 | .end(function (err) { 785 | if (err) { 786 | var serviceMeta = fetcher.getServiceMeta(); 787 | expect(serviceMeta).to.have.length(1); 788 | expect(serviceMeta[0].headers).to.eql({'x-foo': 'foo'}); 789 | done(); 790 | } 791 | }); 792 | }); 793 | describe('should work superagent style', function () { 794 | describe('with callbacks', function () { 795 | it('should throw if no resource is given', function () { 796 | expect(fetcher.read.bind(fetcher)).to.throw('Resource is required for a fetcher request'); 797 | }); 798 | it('should handle CREATE', function (done) { 799 | var operation = 'create'; 800 | fetcher 801 | [operation](resource) 802 | .params(params) 803 | .body(body) 804 | .clientConfig(config) 805 | .end(callback(operation, done)); 806 | }); 807 | it('should handle READ', function (done) { 808 | var operation = 'read'; 809 | fetcher 810 | [operation](resource) 811 | .params(params) 812 | .clientConfig(config) 813 | .end(callback(operation, done)); 814 | }); 815 | it('should handle UPDATE', function (done) { 816 | var operation = 'update'; 817 | fetcher 818 | [operation](resource) 819 | .params(params) 820 | .body(body) 821 | .clientConfig(config) 822 | .end(callback(operation, done)); 823 | }); 824 | it('should handle DELETE', function (done) { 825 | var operation = 'delete'; 826 | fetcher 827 | [operation](resource) 828 | .params(params) 829 | .clientConfig(config) 830 | .end(callback(operation, done)); 831 | }); 832 | }); 833 | describe('with Promises', function () { 834 | it('should throw if no resource is given', function () { 835 | expect(fetcher.read.bind(fetcher)).to.throw('Resource is required for a fetcher request'); 836 | }); 837 | it('should handle CREATE', function (done) { 838 | var operation = 'create'; 839 | fetcher 840 | [operation](resource) 841 | .params(params) 842 | .body(body) 843 | .clientConfig(config) 844 | .end() 845 | .then(resolve(operation, done), reject(operation, done)); 846 | }); 847 | it('should handle READ', function (done) { 848 | var operation = 'read'; 849 | fetcher 850 | [operation](resource) 851 | .params(params) 852 | .clientConfig(config) 853 | .end() 854 | .then(resolve(operation, done), reject(operation, done)); 855 | }); 856 | it('should handle UPDATE', function (done) { 857 | var operation = 'update'; 858 | fetcher 859 | [operation](resource) 860 | .params(params) 861 | .body(body) 862 | .clientConfig(config) 863 | .end() 864 | .then(resolve(operation, done), reject(operation, done)); 865 | }); 866 | it('should handle DELETE', function (done) { 867 | var operation = 'delete'; 868 | fetcher 869 | [operation](resource) 870 | .params(params) 871 | .clientConfig(config) 872 | .end() 873 | .then(resolve(operation, done), reject(operation, done)); 874 | }); 875 | }); 876 | }); 877 | describe('should be backwards compatible', function () { 878 | it('should handle CREATE', function (done) { 879 | var operation = 'create'; 880 | fetcher[operation](resource, params, body, config, callback(operation, done)); 881 | }); 882 | it('should handle CREATE w/ no config', function (done) { 883 | var operation = 'create'; 884 | fetcher[operation](resource, params, body, callback(operation, done)); 885 | }); 886 | it('should handle READ', function (done) { 887 | var operation = 'read'; 888 | fetcher[operation](resource, params, config, callback(operation, done)); 889 | }); 890 | it('should handle READ w/ no config', function (done) { 891 | var operation = 'read'; 892 | fetcher[operation](resource, params, callback(operation, done)); 893 | }); 894 | it('should handle UPDATE', function (done) { 895 | var operation = 'update'; 896 | fetcher[operation](resource, params, body, config, callback(operation, done)); 897 | }); 898 | it('should handle UPDATE w/ no config', function (done) { 899 | var operation = 'update'; 900 | fetcher[operation](resource, params, body, callback(operation, done)); 901 | }); 902 | it('should handle DELETE', function (done) { 903 | var operation = 'delete'; 904 | fetcher[operation](resource, params, config, callback(operation, done)); 905 | }); 906 | it('should handle DELETE w/ no config', function (done) { 907 | var operation = 'delete'; 908 | fetcher[operation](resource, params, callback(operation, done)); 909 | }); 910 | }) 911 | }); 912 | 913 | }); 914 | --------------------------------------------------------------------------------