├── .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 | [](http://badge.fury.io/js/fetchr)
4 | [](https://travis-ci.org/yahoo/fetchr)
5 | [](https://david-dm.org/yahoo/fetchr)
6 | [](https://david-dm.org/yahoo/fetchr#info=devDependencies)
7 | [](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 |
--------------------------------------------------------------------------------