├── 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
├── .prettierignore
├── .npmignore
├── .eslintignore
├── .gitignore
├── tests
├── .eslintrc.json
├── functional
│ ├── resources
│ │ ├── wait.js
│ │ ├── alwaysSlow.js
│ │ ├── headers.js
│ │ ├── slowThenFast.js
│ │ ├── error.js
│ │ └── item.js
│ ├── server.js
│ ├── buildClient.js
│ ├── app.js
│ └── fetchr.test.js
├── mock
│ ├── MockNoopService.js
│ ├── mockApp.js
│ ├── mockCorsApp.js
│ ├── MockErrorService.js
│ └── MockService.js
├── unit
│ ├── setup.js
│ └── libs
│ │ ├── util
│ │ └── httpRequest.js
│ │ └── fetcher.client.js
└── util
│ ├── defaultOptions.js
│ └── testCrud.js
├── .github
├── dependabot.yml
└── workflows
│ └── node.js.yml
├── libs
├── util
│ ├── forEach.js
│ ├── defaultConstructGetUri.js
│ ├── pickContext.js
│ ├── FetchrError.js
│ ├── url.js
│ ├── normalizeOptions.js
│ └── httpRequest.js
├── fetcher.client.js
└── fetcher.js
├── CONTRIBUTING.md
├── .eslintrc.js
├── .editorconfig
├── LICENSE.md
├── package.json
├── docs
└── fetchr.md
└── README.md
/examples/simple/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | client/build/
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .nyc_output
2 | coverage
3 | node_modules
4 | package*.json
--------------------------------------------------------------------------------
/examples/simple/shared/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | xhrPath: '/myapi',
3 | };
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .*
2 | *.yml
3 | /coverage/
4 | /docs/
5 | /artifacts/
6 | /examples/
7 | /tests/
8 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /.github/
2 | /artifacts
3 | /build
4 | /node_modules
5 | tests/functional/static
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /artifacts/
2 | .nyc_output
3 | coverage
4 | node_modules
5 | npm-debug.log
6 | tests/functional/static
7 |
--------------------------------------------------------------------------------
/tests/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": { "es2021": true },
3 | "rules": {
4 | "dot-notation": [2, { "allowKeywords": true }]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tests/functional/resources/wait.js:
--------------------------------------------------------------------------------
1 | module.exports = function wait(time) {
2 | return new Promise((resolve) => setTimeout(() => resolve(), time));
3 | };
4 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: '/'
5 | schedule:
6 | interval: monthly
7 | time: '13:00'
8 | open-pull-requests-limit: 10
9 |
--------------------------------------------------------------------------------
/libs/util/forEach.js:
--------------------------------------------------------------------------------
1 | function forEach(object, fn) {
2 | for (var key in object) {
3 | if (object.hasOwnProperty(key)) {
4 | fn(object[key], key);
5 | }
6 | }
7 | }
8 |
9 | module.exports = forEach;
10 |
--------------------------------------------------------------------------------
/tests/functional/server.js:
--------------------------------------------------------------------------------
1 | const app = require('./app');
2 | const buildClient = require('./buildClient');
3 |
4 | buildClient().then(() => {
5 | app.listen(3000, () => {
6 | console.log('http://localhost:3000');
7 | });
8 | });
9 |
--------------------------------------------------------------------------------
/tests/mock/MockNoopService.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2016, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 |
6 | var MockNoopService = {
7 | resource: 'mock_noop_service',
8 | };
9 |
10 | module.exports = MockNoopService;
11 |
--------------------------------------------------------------------------------
/examples/simple/shared/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetchr Example
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Code to `fetchr`
2 |
3 | Please be sure to read our [CLA][] before you submit pull requests or otherwise contribute to `fetchr`. This protects developers, who rely on [BSD license][].
4 |
5 | [bsd license]: https://github.com/yahoo/fetchr/blob/master/LICENSE.md
6 | [cla]: https://github.com/yahoo/.github/blob/master/PULL_REQUEST_TEMPLATE.md
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2023: true,
5 | mocha: true,
6 | node: true,
7 | },
8 | extends: ['eslint:recommended'],
9 | rules: {
10 | 'no-prototype-builtins': 0,
11 | 'no-unexpected-multiline': 0,
12 | },
13 | globals: {
14 | Promise: 'readonly',
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/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 |
6 | // TODO: remove this module as soon as fluxible-plugin-fetchr starts
7 | // using the new url module.
8 |
9 | 'use strict';
10 |
11 | var url = require('./url');
12 |
13 | module.exports = url.buildGETUrl;
14 |
--------------------------------------------------------------------------------
/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": "^3.8.3"
14 | },
15 | "devDependencies": {
16 | "webpack": "^1.3.0-beta8"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tests/functional/resources/alwaysSlow.js:
--------------------------------------------------------------------------------
1 | const wait = require('./wait');
2 |
3 | // This resource allows us to exercise timeout and abort capacities of
4 | // the fetchr client.
5 |
6 | const alwaysSlowService = {
7 | resource: 'slow',
8 | async read() {
9 | await wait(5000);
10 | return { data: { ok: true } };
11 | },
12 | async create() {
13 | await wait(5000);
14 | return { data: { ok: true } };
15 | },
16 | };
17 |
18 | module.exports = {
19 | alwaysSlowService,
20 | };
21 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/tests/functional/resources/headers.js:
--------------------------------------------------------------------------------
1 | const headersService = {
2 | resource: 'header',
3 |
4 | async read({ req }) {
5 | if (req.headers['x-fetchr-request'] !== '42') {
6 | const err = new Error('missing x-fetchr header');
7 | err.statusCode = 400;
8 | throw err;
9 | }
10 | return {
11 | data: {
12 | headers: 'ok',
13 | },
14 | meta: {
15 | headers: {
16 | 'x-fetchr-response': '42',
17 | },
18 | },
19 | };
20 | },
21 | };
22 |
23 | module.exports = {
24 | headersService,
25 | };
26 |
--------------------------------------------------------------------------------
/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 &&
15 | callback(new Error('failed to fetch data ' + err.message));
16 | }
17 | callback && callback(null, data);
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/tests/unit/setup.js:
--------------------------------------------------------------------------------
1 | // As seen in isomorphic-fetch fetch polyfill:
2 | // https://github.com/matthew-andrews/isomorphic-fetch/blob/fc5e0d0d0b180e5b4c70b2ae7f738c50a9a51b25/fetch-npm-node.js
3 |
4 | 'use strict';
5 |
6 | const nodeFetch = require('node-fetch');
7 | const {
8 | AbortController,
9 | abortableFetch,
10 | } = require('abortcontroller-polyfill/dist/cjs-ponyfill');
11 |
12 | const { fetch, Request } = abortableFetch({
13 | fetch: nodeFetch,
14 | Request: nodeFetch.Request,
15 | });
16 |
17 | global.AbortController = AbortController;
18 | global.Headers = nodeFetch.Headers;
19 | global.Request = Request;
20 | global.Response = nodeFetch.Response;
21 | global.fetch = fetch;
22 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [18.x, 20.x]
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 | - run: npm i
24 | - run: npm run lint
25 | - run: npm run test:coverage
26 | - run: npm run test:functional
27 |
--------------------------------------------------------------------------------
/tests/functional/resources/slowThenFast.js:
--------------------------------------------------------------------------------
1 | const wait = require('./wait');
2 |
3 | // This resource gives 2 slow responses and then a fast one. This is
4 | // so, so we can test that fetchr client is able to retry timed out
5 | // requests.
6 |
7 | const state = {
8 | count: 0,
9 | };
10 |
11 | const slowThenFastService = {
12 | resource: 'slow-then-fast',
13 | async read({ params }) {
14 | if (params.reset) {
15 | state.count = 0;
16 | return {};
17 | }
18 |
19 | const timeout = state.count === 2 ? 0 : 5000;
20 | state.count++;
21 |
22 | await wait(timeout);
23 |
24 | return { data: { attempts: state.count } };
25 | },
26 | };
27 |
28 | module.exports = {
29 | slowThenFastService,
30 | };
31 |
--------------------------------------------------------------------------------
/tests/mock/mockApp.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2016, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | var DEFAULT_PATH = '/api';
6 |
7 | var express = require('express');
8 | var app = express();
9 |
10 | var FetcherServer = require('../../libs/fetcher');
11 | var mockService = require('./MockService');
12 | var mockErrorService = require('./MockErrorService');
13 | var mockNoopService = require('./MockNoopService');
14 | FetcherServer.registerService(mockService);
15 | FetcherServer.registerService(mockErrorService);
16 | FetcherServer.registerService(mockNoopService);
17 |
18 | app.use(express.json());
19 | app.use(DEFAULT_PATH, FetcherServer.middleware());
20 |
21 | module.exports = app;
22 | module.exports.DEFAULT_PATH = DEFAULT_PATH;
23 |
--------------------------------------------------------------------------------
/tests/functional/buildClient.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 |
4 | async function buildClient() {
5 | return new Promise((resolve, reject) => {
6 | webpack(
7 | {
8 | mode: 'production',
9 | entry: path.resolve(__dirname, '../../libs/fetcher.client.js'),
10 | output: {
11 | filename: 'fetchr.umd.js',
12 | path: path.resolve(__dirname, 'static'),
13 | library: {
14 | name: 'Fetchr',
15 | type: 'umd',
16 | },
17 | },
18 | },
19 | (err, stats) => {
20 | if (err) {
21 | reject(err);
22 | } else {
23 | resolve(stats);
24 | }
25 | },
26 | );
27 | });
28 | }
29 |
30 | module.exports = buildClient;
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/mock/mockCorsApp.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2016, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | var express = require('express');
6 | var app = express();
7 |
8 | var FetcherServer = require('../../libs/fetcher');
9 | var mockService = require('./MockService');
10 | var mockErrorService = require('./MockErrorService');
11 | var mockNoopService = require('./MockNoopService');
12 | FetcherServer.registerService(mockService);
13 | FetcherServer.registerService(mockErrorService);
14 | FetcherServer.registerService(mockNoopService);
15 |
16 | app.use(express.json());
17 | app.use(function cors(req, res, next) {
18 | if (req.query.cors) {
19 | res.set('Access-Control-Allow-Origin', '*');
20 | next();
21 | } else {
22 | res.sendStatus(403);
23 | }
24 | });
25 | app.use(FetcherServer.middleware());
26 |
27 | var CORS_PORT = 3001;
28 | module.exports = app.listen(CORS_PORT);
29 | module.exports.corsPath = 'http://localhost:' + CORS_PORT;
30 |
--------------------------------------------------------------------------------
/tests/functional/resources/error.js:
--------------------------------------------------------------------------------
1 | const wait = require('./wait');
2 |
3 | const retryToggle = { error: true };
4 |
5 | const errorsService = {
6 | resource: 'error',
7 | async read({ params }) {
8 | if (params.error === 'unexpected') {
9 | throw new Error('unexpected');
10 | }
11 |
12 | if (params.error === 'timeout') {
13 | await wait(100);
14 | return { data: { ok: true } };
15 | }
16 |
17 | if (params.error === 'retry') {
18 | if (retryToggle.error) {
19 | retryToggle.error = false;
20 | const err = new Error('retry');
21 | err.statusCode = 408;
22 | throw err;
23 | }
24 |
25 | return { data: { retry: 'ok' } };
26 | }
27 |
28 | const err = new Error('error');
29 | err.statusCode = 400;
30 |
31 | return {
32 | err,
33 | meta: {
34 | foo: 'bar',
35 | },
36 | };
37 | },
38 |
39 | async create({ params }) {
40 | return this.read({ params });
41 | },
42 | };
43 |
44 | module.exports = {
45 | retryToggle,
46 | errorsService,
47 | };
48 |
--------------------------------------------------------------------------------
/libs/util/pickContext.js:
--------------------------------------------------------------------------------
1 | var forEach = require('./forEach');
2 |
3 | /**
4 | * Pick keys from the context object
5 | * @method pickContext
6 | * @param {Object} context - context object
7 | * @param {Function|Array|String} picker - key, array of keys or
8 | * function that return keys to be extracted from context.
9 | * @param {String} method - method name, GET or POST
10 | */
11 | function pickContext(context, picker, method) {
12 | if (!picker || !picker[method]) {
13 | return context;
14 | }
15 |
16 | var p = picker[method];
17 | var result = {};
18 |
19 | if (typeof p === 'string') {
20 | result[p] = context[p];
21 | } else if (Array.isArray(p)) {
22 | p.forEach(function (key) {
23 | result[key] = context[key];
24 | });
25 | } else if (typeof p === 'function') {
26 | forEach(context, function (value, key) {
27 | if (p(value, key, context)) {
28 | result[key] = context[key];
29 | }
30 | });
31 | } else {
32 | throw new TypeError(
33 | 'picker must be an string, an array, or a function.',
34 | );
35 | }
36 |
37 | return result;
38 | }
39 |
40 | module.exports = pickContext;
41 |
--------------------------------------------------------------------------------
/libs/util/FetchrError.js:
--------------------------------------------------------------------------------
1 | function FetchrError(reason, message, options, request, response) {
2 | this.body = null;
3 | this.message = message;
4 | this.meta = null;
5 | this.name = 'FetchrError';
6 | this.output = null;
7 | this.rawRequest = {
8 | headers: options.headers,
9 | method: request.method,
10 | url: request.url,
11 | };
12 | this.reason = reason;
13 | this.statusCode = response ? response.status : 0;
14 | this.timeout = options.timeout;
15 | this.url = request.url;
16 |
17 | if (response) {
18 | try {
19 | this.body = JSON.parse(message);
20 | this.output = this.body.output || null;
21 | this.meta = this.body.meta || null;
22 | this.message = this.body.message || message;
23 | } catch (e) {
24 | this.body = message;
25 | }
26 | }
27 | }
28 |
29 | FetchrError.prototype = Object.create(Error.prototype);
30 | FetchrError.prototype.constructor = FetchrError;
31 |
32 | FetchrError.ABORT = 'ABORT';
33 | FetchrError.BAD_HTTP_STATUS = 'BAD_HTTP_STATUS';
34 | FetchrError.BAD_JSON = 'BAD_JSON';
35 | FetchrError.TIMEOUT = 'TIMEOUT';
36 | FetchrError.UNKNOWN = 'UNKNOWN';
37 |
38 | module.exports = FetchrError;
39 |
--------------------------------------------------------------------------------
/tests/functional/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 | const Fetchr = require('../../libs/fetcher');
4 | const { itemsService } = require('./resources/item');
5 | const { errorsService } = require('./resources/error');
6 | const { headersService } = require('./resources/headers');
7 | const { alwaysSlowService } = require('./resources/alwaysSlow');
8 | const { slowThenFastService } = require('./resources/slowThenFast');
9 |
10 | Fetchr.registerService(itemsService);
11 | Fetchr.registerService(errorsService);
12 | Fetchr.registerService(headersService);
13 | Fetchr.registerService(alwaysSlowService);
14 | Fetchr.registerService(slowThenFastService);
15 |
16 | const app = express();
17 |
18 | app.use(express.json());
19 |
20 | app.use('/static', express.static(path.join(__dirname, 'static')));
21 |
22 | app.use('/api', Fetchr.middleware(), (err, req, res, next) => {
23 | res.status(err.statusCode || 500).json({ message: err.message });
24 | });
25 |
26 | app.get('/', (req, res) => {
27 | res.send(`
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | `);
38 | });
39 |
40 | app.use(function (req, res) {
41 | res.status(404).send({ error: 'page not found' });
42 | });
43 |
44 | module.exports = app;
45 |
--------------------------------------------------------------------------------
/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 | resource: '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.get(url).end(function (err, res) {
25 | callback(err, JSON.parse(res.text));
26 | });
27 | },
28 | //TODO: other methods
29 | //create: function(req, resource, params, body, config, callback) {},
30 | //update: function(req, resource, params, body, config, callback) {},
31 | //delete: function(req, resource, params, config, callback) {}
32 | };
33 |
34 | module.exports = FlickrFetcher;
35 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 |
3 | ## Copyright (c) 2014, Yahoo! Inc. All rights reserved.
4 |
5 | Redistribution and use of this software in source and binary forms, with or
6 | without modification, are permitted provided that the following conditions are
7 | met:
8 |
9 | - Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 | - Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 | - Neither the name of Yahoo! Inc. nor the names of YUI's contributors may be
15 | used to endorse or promote products derived from this software without
16 | specific prior written permission of Yahoo! Inc.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/tests/functional/resources/item.js:
--------------------------------------------------------------------------------
1 | const itemsData = {};
2 |
3 | const itemsService = {
4 | resource: 'item',
5 |
6 | async create({ params, body }) {
7 | const item = {
8 | id: params.id,
9 | value: body.value,
10 | };
11 | itemsData[item.id] = item;
12 | return { data: item, meta: { statusCode: 201 } };
13 | },
14 |
15 | async read({ params }) {
16 | if (params.id) {
17 | const item = itemsData[params.id];
18 | if (!item) {
19 | const err = new Error('not found');
20 | err.statusCode = 404;
21 | return { err, meta: { foo: 42 } };
22 | } else {
23 | return { data: item, meta: { statusCode: 200 } };
24 | }
25 | } else {
26 | return {
27 | data: Object.values(itemsData),
28 | meta: { statusCode: 200 },
29 | };
30 | }
31 | },
32 |
33 | async update({ params, body }) {
34 | const item = itemsData[params.id];
35 | if (!item) {
36 | const err = new Error('not found');
37 | err.statusCode = 404;
38 | throw err;
39 | } else {
40 | const updatedItem = { ...item, ...body };
41 | itemsData[params.id] = updatedItem;
42 | return { data: updatedItem, meta: { statusCode: 201 } };
43 | }
44 | },
45 |
46 | async delete({ params }) {
47 | try {
48 | delete itemsData[params.id];
49 | return { data: null, meta: { statusCode: 200 } };
50 | } catch {
51 | const err = new Error('not found');
52 | err.statusCode = 404;
53 | throw err;
54 | }
55 | },
56 | };
57 |
58 | module.exports = {
59 | itemsData,
60 | itemsService,
61 | };
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fetchr",
3 | "version": "0.7.14",
4 | "description": "Fetchr augments Flux applications by allowing Flux stores to be used on server and client to fetch data",
5 | "main": "./libs/fetcher.js",
6 | "browser": "./libs/fetcher.client.js",
7 | "scripts": {
8 | "format": "prettier --write .",
9 | "format:check": "prettier --check .",
10 | "lint": "npm run lint:client && npm run lint:server && npm run format:check",
11 | "lint:client": "eslint libs/util/ libs/fetcher.client.js",
12 | "lint:server": "eslint --parser-options=ecmaVersion:latest libs/fetcher.js",
13 | "test": "npm run test:unit && npm run test:functional",
14 | "test:coverage": "nyc --reporter=lcov npm run test:unit",
15 | "test:unit": "NODE_ENV=test mocha tests/unit/ --recursive --reporter spec --timeout 20000 --exit --require tests/unit/setup.js",
16 | "test:functional": "NODE_ENV=test mocha tests/functional/*.test.js --reporter spec --exit -t 10000"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git@github.com:yahoo/fetchr"
21 | },
22 | "author": "Rajiv Tirumalareddy ",
23 | "licenses": [
24 | {
25 | "type": "BSD",
26 | "url": "https://github.com/yahoo/fetchr/blob/master/LICENSE.md"
27 | }
28 | ],
29 | "dependencies": {
30 | "fumble": "^0.1.0"
31 | },
32 | "devDependencies": {
33 | "abortcontroller-polyfill": "^1.7.3",
34 | "chai": "^4.2.0",
35 | "eslint": "^8.44.0",
36 | "express": "^4.17.1",
37 | "fetch-mock": "^10.1.1",
38 | "mocha": "^10.0.0",
39 | "mockery": "^2.0.0",
40 | "node-fetch": "^2.6.2",
41 | "nyc": "^17.0.0",
42 | "prettier": "^3.0.0",
43 | "puppeteer": "^23.2.1",
44 | "sinon": "^19.0.2",
45 | "supertest": "7.0.0",
46 | "webpack": "^5.51.1"
47 | },
48 | "keywords": [
49 | "yahoo",
50 | "flux",
51 | "react",
52 | "fetchr",
53 | "dispatchr"
54 | ],
55 | "prettier": {
56 | "singleQuote": true,
57 | "tabWidth": 4
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/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 | app.use('/server', function (req, res) {
26 | var fetcher = new Fetcher({ req: req });
27 |
28 | //client specific callback
29 | readFlickrServer = function (err, data) {
30 | if (err) {
31 | throw err;
32 | }
33 |
34 | //server specific logic
35 | var tpl = fs.readFileSync(templatePath, { encoding: 'utf8' }),
36 | output = JSON.stringify(data);
37 |
38 | // set the environment h1
39 | tpl = tpl.replace('', 'Server
');
40 | // remove script tag
41 | tpl = tpl.replace('', '');
42 | // output the data
43 | tpl = tpl.replace('', output);
44 |
45 | res.send(tpl);
46 | };
47 |
48 | //client-server agnostic call for data
49 | readFlickr(fetcher, readFlickrServer);
50 | });
51 |
52 | //For the webpack built app.js that is needed by the index.html client file
53 | app.use(express['static'](path.join(__dirname, '..', 'client', 'build')));
54 |
55 | //For the index.html file
56 | app.use('/client', function (req, res) {
57 | var tpl = fs.readFileSync(templatePath, { encoding: 'utf8' });
58 | res.send(tpl);
59 | });
60 |
61 | var port = process.env.PORT || 3000;
62 | http.createServer(app).listen(port);
63 | console.log('Listening on port ' + port);
64 |
--------------------------------------------------------------------------------
/tests/util/defaultOptions.js:
--------------------------------------------------------------------------------
1 | var expect = require('chai').expect;
2 | var mockService = require('../mock/MockService');
3 |
4 | var resource = mockService.resource;
5 |
6 | const removeUndefinedProperties = (obj) =>
7 | Object.fromEntries(
8 | Object.entries(obj).filter((entry) => entry[1] !== undefined),
9 | );
10 |
11 | var params = {
12 | uuids: ['1', '2', '3', '4', '5'],
13 | meta: {
14 | headers: {
15 | 'x-foo-bar': 'foobar',
16 | },
17 | },
18 | missing: undefined,
19 | };
20 | var body = { stuff: 'is' };
21 | var config = {};
22 | var callback = function (operation, done) {
23 | return function (err, data, meta) {
24 | if (err) {
25 | return done(err);
26 | }
27 | expect(data.operation).to.exist;
28 | expect(data.operation.name).to.equal(operation);
29 | expect(data.operation.success).to.be.true;
30 | expect(data.args).to.exist;
31 | expect(data.args.resource).to.equal(resource);
32 | expect(data.args.params).to.eql(removeUndefinedProperties(params));
33 | expect(meta).to.eql(params.meta);
34 | done();
35 | };
36 | };
37 | var resolve = function (operation, done) {
38 | return function (result) {
39 | try {
40 | expect(result).to.exist;
41 | expect(result).to.have.keys('data', 'meta');
42 | expect(result.data.operation).to.exist;
43 | expect(result.data.operation.name).to.equal(operation);
44 | expect(result.data.operation.success).to.be.true;
45 | expect(result.data.args).to.exist;
46 | expect(result.data.args.resource).to.equal(resource);
47 | expect(result.data.args.params).to.eql(
48 | removeUndefinedProperties(params),
49 | );
50 | expect(result.meta).to.eql(params.meta);
51 | } catch (e) {
52 | done(e);
53 | return;
54 | }
55 | done();
56 | };
57 | };
58 | var reject = function (operation, done) {
59 | return function (err) {
60 | done(err);
61 | };
62 | };
63 |
64 | module.exports.resource = resource;
65 | module.exports.params = params;
66 | module.exports.body = body;
67 | module.exports.config = config;
68 | module.exports.callback = callback;
69 | module.exports.resolve = resolve;
70 | module.exports.reject = reject;
71 |
--------------------------------------------------------------------------------
/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 all requests. Will be ignored serverside.
10 | - `options.xhrTimeout` (optional): Timeout in milliseconds for all 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 | resource: 'serviceResource',
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 | resource: 'mock_error_service',
7 |
8 | read: async function ({ req, params }) {
9 | const meta = this.meta || params.meta;
10 | this.meta = null;
11 |
12 | if (
13 | req.query &&
14 | req.query.cors &&
15 | params &&
16 | Object.keys(params).length === 0
17 | ) {
18 | // in our CORS test, we use regular query params instead
19 | // of matrix params for the params object will be empty
20 | // create params from req.query but omit the context
21 | // values(i.e. cors)
22 | params = {};
23 | for (const [key, value] of Object.entries(req.query)) {
24 | if (['cors', '_csrf'].includes(key)) {
25 | continue;
26 | }
27 | params[key] = value;
28 | }
29 | }
30 | return {
31 | err: {
32 | statusCode: parseInt(params.statusCode),
33 | output: params.output,
34 | message: params.message,
35 | read: 'error',
36 | },
37 | data: null,
38 | meta,
39 | };
40 | },
41 |
42 | create: async function ({ params }) {
43 | const meta = this.meta || params.meta;
44 | this.meta = null;
45 |
46 | return {
47 | err: {
48 | statusCode: parseInt(params.statusCode),
49 | message: params.message,
50 | output: params.output,
51 | create: 'error',
52 | },
53 | data: null,
54 | meta,
55 | };
56 | },
57 |
58 | update: async function ({ params }) {
59 | return {
60 | err: {
61 | statusCode: parseInt(params.statusCode),
62 | message: params.message,
63 | output: params.output,
64 | update: 'error',
65 | },
66 | data: null,
67 | };
68 | },
69 |
70 | delete: async function ({ params }) {
71 | return {
72 | err: {
73 | statusCode: parseInt(params.statusCode),
74 | message: params.message,
75 | output: params.output,
76 | delete: 'error',
77 | },
78 | data: null,
79 | };
80 | },
81 | };
82 |
83 | module.exports = MockErrorService;
84 |
--------------------------------------------------------------------------------
/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 MockService = {
6 | resource: 'mock_service',
7 |
8 | read: async function ({ req, resource, params }) {
9 | const meta = this.meta || params.meta;
10 | this.meta = null;
11 |
12 | if (
13 | req.query &&
14 | req.query.cors &&
15 | params &&
16 | Object.keys(params).length === 0
17 | ) {
18 | // in our CORS test, we use regular query params instead
19 | // of matrix params for the params object will be empty
20 | // create params from req.query but omit the context
21 | // values(i.e. cors)
22 | params = {};
23 | for (const [key, value] of Object.entries(req.query)) {
24 | if (['cors', '_csrf'].includes(key)) {
25 | continue;
26 | }
27 | params[key] = value;
28 | }
29 | }
30 |
31 | return {
32 | data: {
33 | operation: {
34 | name: 'read',
35 | success: true,
36 | },
37 | args: {
38 | resource: resource,
39 | params: params,
40 | },
41 | },
42 | meta,
43 | };
44 | },
45 |
46 | create: async function ({ resource, params }) {
47 | const meta = this.meta || params.meta;
48 | this.meta = null;
49 |
50 | return {
51 | data: {
52 | operation: {
53 | name: 'create',
54 | success: true,
55 | },
56 | args: {
57 | resource: resource,
58 | params: params,
59 | },
60 | },
61 | err: null,
62 | meta,
63 | };
64 | },
65 |
66 | update: async function ({ resource, params }) {
67 | const meta = this.meta || params.meta;
68 | this.meta = null;
69 |
70 | return {
71 | data: {
72 | operation: {
73 | name: 'update',
74 | success: true,
75 | },
76 | args: {
77 | resource: resource,
78 | params: params,
79 | },
80 | },
81 | meta,
82 | };
83 | },
84 |
85 | delete: async function ({ resource, params }) {
86 | const meta = this.meta || params.meta;
87 | this.meta = null;
88 |
89 | return {
90 | data: {
91 | operation: {
92 | name: 'delete',
93 | success: true,
94 | },
95 | args: {
96 | resource: resource,
97 | params: params,
98 | },
99 | },
100 | meta,
101 | };
102 | },
103 | };
104 |
105 | module.exports = MockService;
106 |
--------------------------------------------------------------------------------
/libs/util/url.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 forEach = require('./forEach');
8 | var pickContext = require('./pickContext');
9 |
10 | function isObject(value) {
11 | var type = typeof value;
12 | return value != null && (type == 'object' || type == 'function');
13 | }
14 |
15 | function jsonifyComplexType(value) {
16 | if (Array.isArray(value) || isObject(value)) {
17 | return JSON.stringify(value);
18 | }
19 | return value;
20 | }
21 |
22 | /**
23 | * Construct GET URL.
24 | * @param {String} baseUrl
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 | function buildGETUrl(baseUrl, resource, params, config, context) {
32 | var query = [];
33 | var matrix = [];
34 | var idParam = config.id_param;
35 | var idVal;
36 | var finalUrl = baseUrl + '/' + resource;
37 |
38 | if (params) {
39 | forEach(params, function eachParam(v, k) {
40 | if (k === idParam) {
41 | idVal = encodeURIComponent(v);
42 | } else if (v !== undefined) {
43 | try {
44 | matrix.push(
45 | k + '=' + encodeURIComponent(jsonifyComplexType(v)),
46 | );
47 | } catch (err) {
48 | console.debug('jsonifyComplexType failed: ' + err);
49 | }
50 | }
51 | });
52 | }
53 |
54 | if (context) {
55 | forEach(context, function eachContext(v, k) {
56 | query.push(k + '=' + encodeURIComponent(jsonifyComplexType(v)));
57 | });
58 | }
59 |
60 | if (idVal) {
61 | finalUrl += '/' + idParam + '/' + idVal;
62 | }
63 | if (matrix.length > 0) {
64 | finalUrl += ';' + matrix.sort().join(';');
65 | }
66 | if (query.length > 0) {
67 | finalUrl += '?' + query.sort().join('&');
68 | }
69 |
70 | return finalUrl;
71 | }
72 |
73 | /**
74 | * Build a final url by adding query params to the base url from
75 | * request.context
76 | * @param {String} baseUrl
77 | * @param {Request} request
78 | */
79 | function buildPOSTUrl(baseUrl, request) {
80 | var query = [];
81 | var finalUrl = baseUrl;
82 |
83 | // We only want to append the resource if the uri is the fetchr
84 | // one. If users set a custom uri (through clientConfig method or
85 | // by passing a config obejct to the request), we should not
86 | // modify it.
87 | if (!request._clientConfig.uri) {
88 | finalUrl += '/' + request.resource;
89 | }
90 |
91 | forEach(
92 | pickContext(
93 | request.options.context,
94 | request.options.contextPicker,
95 | 'POST',
96 | ),
97 | function eachContext(v, k) {
98 | query.push(k + '=' + encodeURIComponent(v));
99 | },
100 | );
101 | if (query.length > 0) {
102 | finalUrl += '?' + query.sort().join('&');
103 | }
104 | return finalUrl;
105 | }
106 |
107 | module.exports = {
108 | buildGETUrl: buildGETUrl,
109 | buildPOSTUrl: buildPOSTUrl,
110 | };
111 |
--------------------------------------------------------------------------------
/libs/util/normalizeOptions.js:
--------------------------------------------------------------------------------
1 | var pickContext = require('./pickContext');
2 | var url = require('./url');
3 |
4 | var MAX_URI_LEN = 2048;
5 |
6 | function requestToOptions(request) {
7 | var options = {};
8 |
9 | var config = Object.assign(
10 | {
11 | xhrTimeout: request.options.xhrTimeout,
12 | },
13 | request._clientConfig,
14 | );
15 | options.config = config;
16 | options.headers = config.headers || request.options.headers || {};
17 |
18 | var baseUrl = config.uri;
19 | if (!baseUrl) {
20 | baseUrl = config.cors
21 | ? request.options.corsPath
22 | : request.options.xhrPath;
23 | }
24 |
25 | if (request.operation === 'read' && !config.post_for_read) {
26 | options.method = 'GET';
27 |
28 | var buildGetUrl =
29 | typeof config.constructGetUri === 'function'
30 | ? config.constructGetUri
31 | : url.buildGETUrl;
32 |
33 | var context = pickContext(
34 | request.options.context,
35 | request.options.contextPicker,
36 | 'GET',
37 | );
38 |
39 | var args = [
40 | baseUrl,
41 | request.resource,
42 | request._params,
43 | request._clientConfig,
44 | context,
45 | ];
46 |
47 | // If a custom getUriFn returns falsy value, we should run urlUtil.buildGETUrl
48 | // TODO: Add test for this fallback
49 | options.url =
50 | buildGetUrl.apply(request, args) ||
51 | url.buildGETUrl.apply(request, args);
52 |
53 | if (options.url.length <= MAX_URI_LEN) {
54 | return options;
55 | }
56 | }
57 |
58 | options.method = 'POST';
59 | options.url = url.buildPOSTUrl(baseUrl, request);
60 | options.data = {
61 | body: request._body,
62 | context: request.options.context,
63 | operation: request.operation,
64 | params: request._params,
65 | resource: request.resource,
66 | };
67 |
68 | return options;
69 | }
70 |
71 | function normalizeHeaders(options) {
72 | var headers = Object.assign({}, options.headers);
73 |
74 | if (!options.config.cors) {
75 | headers['X-Requested-With'] = 'XMLHttpRequest';
76 | }
77 |
78 | if (options.method === 'POST') {
79 | headers['Content-Type'] = 'application/json';
80 | }
81 |
82 | return headers;
83 | }
84 |
85 | function normalizeRetry(request) {
86 | var retry = Object.assign(
87 | {
88 | interval: 200,
89 | maxRetries: 0,
90 | retryOnPost:
91 | request.operation === 'read' ||
92 | request.options.unsafeAllowRetry,
93 | statusCodes: [0, 408, 999],
94 | },
95 | request.options.retry,
96 | request._clientConfig.retry,
97 | );
98 |
99 | if ('unsafeAllowRetry' in request._clientConfig) {
100 | retry.retryOnPost = request._clientConfig.unsafeAllowRetry;
101 | }
102 |
103 | if (retry.max_retries) {
104 | console.warn(
105 | '"max_retries" is deprecated and will be removed in a future release, use "maxRetries" instead.',
106 | );
107 | retry.maxRetries = retry.max_retries;
108 | }
109 |
110 | return retry;
111 | }
112 |
113 | function normalizeOptions(request) {
114 | var options = requestToOptions(request);
115 | return {
116 | credentials: options.config.withCredentials ? 'include' : 'same-origin',
117 | body: options.data != null ? JSON.stringify(options.data) : undefined,
118 | headers: normalizeHeaders(options),
119 | method: options.method,
120 | retry: normalizeRetry(request),
121 | timeout: options.config.timeout || options.config.xhrTimeout,
122 | url: options.url,
123 | };
124 | }
125 |
126 | module.exports = normalizeOptions;
127 |
--------------------------------------------------------------------------------
/libs/util/httpRequest.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 | /**
7 | * @module httpRequest
8 | */
9 |
10 | var FetchrError = require('./FetchrError');
11 |
12 | function _shouldRetry(err) {
13 | if (err.reason === FetchrError.ABORT) {
14 | return false;
15 | }
16 |
17 | if (this._currentAttempt >= this._options.retry.maxRetries) {
18 | return false;
19 | }
20 |
21 | if (this._options.method === 'POST' && !this._options.retry.retryOnPost) {
22 | return false;
23 | }
24 |
25 | return this._options.retry.statusCodes.indexOf(err.statusCode) !== -1;
26 | }
27 |
28 | // _retry is the onReject promise callback that we attach to the
29 | // _fetch call (ex. _fetch().catch(_retry)). Since _fetch is a promise
30 | // and since we must be able to retry requests (aka call _fetch
31 | // function again), we must call _fetch from within _retry. This means
32 | // that _fetch is a recursive function. Recursive promises are
33 | // problematic since they can block the main thread for a
34 | // while. However, since the inner _fetch call is wrapped in a
35 | // setTimeout we are safe here.
36 | //
37 | // The call flow:
38 | //
39 | // send -> _fetch -> _retry -> _fetch -> _retry -> end
40 | function _retry(err) {
41 | var self = this;
42 | if (!_shouldRetry.call(self, err)) {
43 | throw err;
44 | }
45 |
46 | // Use exponential backoff and full jitter
47 | // strategy published in
48 | // https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
49 | var delay =
50 | Math.random() *
51 | self._options.retry.interval *
52 | Math.pow(2, self._currentAttempt);
53 |
54 | self._controller = new AbortController();
55 | self._currentAttempt += 1;
56 |
57 | return new Promise(function (resolve, reject) {
58 | setTimeout(function () {
59 | _fetch.call(self).then(resolve, reject);
60 | }, delay);
61 | });
62 | }
63 |
64 | function _fetch() {
65 | var self = this;
66 | var timedOut = false;
67 | var request = new Request(self._options.url, {
68 | body: self._options.body,
69 | credentials: self._options.credentials,
70 | headers: self._options.headers,
71 | method: self._options.method,
72 | signal: self._controller.signal,
73 | });
74 |
75 | var timeoutId = setTimeout(function () {
76 | timedOut = true;
77 | self._controller.abort();
78 | }, self._options.timeout);
79 |
80 | return fetch(request)
81 | .then(
82 | function (response) {
83 | clearTimeout(timeoutId);
84 |
85 | if (response.ok) {
86 | return response.json().catch(function () {
87 | throw new FetchrError(
88 | FetchrError.BAD_JSON,
89 | 'Cannot parse response into a JSON object',
90 | self._options,
91 | request,
92 | response,
93 | );
94 | });
95 | } else {
96 | return response.text().then(function (message) {
97 | throw new FetchrError(
98 | FetchrError.BAD_HTTP_STATUS,
99 | message,
100 | self._options,
101 | request,
102 | response,
103 | );
104 | });
105 | }
106 | },
107 | function (err) {
108 | clearTimeout(timeoutId);
109 | if (err.name === 'AbortError') {
110 | if (timedOut) {
111 | throw new FetchrError(
112 | FetchrError.TIMEOUT,
113 | 'Request failed due to timeout',
114 | self._options,
115 | request,
116 | );
117 | }
118 |
119 | throw new FetchrError(
120 | FetchrError.ABORT,
121 | err.message,
122 | self._options,
123 | request,
124 | );
125 | }
126 |
127 | throw new FetchrError(
128 | FetchrError.UNKNOWN,
129 | err.message,
130 | self._options,
131 | request,
132 | );
133 | },
134 | )
135 | .catch(_retry.bind(self));
136 | }
137 |
138 | function _send() {
139 | this._request = _fetch.call(this);
140 | }
141 |
142 | function FetchrHttpRequest(options) {
143 | this._controller = new AbortController();
144 | this._currentAttempt = 0;
145 | this._options = options;
146 | this._request = null;
147 | }
148 |
149 | FetchrHttpRequest.prototype.abort = function () {
150 | return this._controller.abort();
151 | };
152 |
153 | FetchrHttpRequest.prototype.then = function (resolve, reject) {
154 | return this._request.then(resolve, reject);
155 | };
156 |
157 | FetchrHttpRequest.prototype.catch = function (reject) {
158 | return this._request.catch(reject);
159 | };
160 |
161 | function httpRequest(options) {
162 | var request = new FetchrHttpRequest(options);
163 | _send.call(request);
164 | return request;
165 | }
166 |
167 | module.exports = httpRequest;
168 |
--------------------------------------------------------------------------------
/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 | /**
7 | * Fetcher is a CRUD interface for your data.
8 | * @module Fetcher
9 | */
10 | var httpRequest = require('./util/httpRequest');
11 | var normalizeOptions = require('./util/normalizeOptions');
12 |
13 | var DEFAULT_PATH = '/api';
14 | var DEFAULT_TIMEOUT = 3000;
15 |
16 | /**
17 | * A RequestClient instance represents a single fetcher request.
18 | * The constructor requires `operation` (CRUD) and `resource`.
19 | * @class RequestClient
20 | * @param {String} operation The CRUD operation name: 'create|read|update|delete'.
21 | * @param {String} resource name of fetcher/service
22 | * @param {Object} options configuration options for Request
23 | * @param {Array} [options._serviceMeta] Array to hold per-request/session metadata from all service calls.
24 | * Data will be pushed on to this array while the Fetchr instance maintains the reference for this session.
25 | *
26 | * @constructor
27 | */
28 | function Request(operation, resource, options) {
29 | if (!resource) {
30 | throw new Error('Resource is required for a fetcher request');
31 | }
32 |
33 | this.operation = operation;
34 | this.resource = resource;
35 | this.options = options;
36 |
37 | this._params = {};
38 | this._body = null;
39 | this._clientConfig = {};
40 | this._startTime = 0;
41 | this._request = null;
42 | }
43 |
44 | /**
45 | * Add params to this fetcher request
46 | * @method params
47 | * @memberof Request
48 | * @param {Object} params Information carried in query and matrix parameters in typical REST API
49 | * @chainable
50 | */
51 | Request.prototype.params = function (params) {
52 | this._params = params || {};
53 | return this;
54 | };
55 |
56 | /**
57 | * Add body to this fetcher request
58 | * @method body
59 | * @memberof Request
60 | * @param {Object} body The JSON object that contains the resource data being updated for this request.
61 | * Not used for read and delete operations.
62 | * @chainable
63 | */
64 | Request.prototype.body = function (body) {
65 | this._body = body || null;
66 | return this;
67 | };
68 |
69 | /**
70 | * Add clientConfig to this fetcher request
71 | * @method clientConfig
72 | * @memberof Request
73 | * @param {Object} config config for this fetcher request
74 | * @chainable
75 | */
76 | Request.prototype.clientConfig = function (config) {
77 | this._clientConfig = config || {};
78 | return this;
79 | };
80 |
81 | /**
82 | * capture meta data; capture stats for this request and pass stats data
83 | * to options.statsCollector
84 | * @method _captureMetaAndStats
85 | * @param {Object} err The error response for failed request
86 | * @param {Object} result The response data for successful request
87 | */
88 | Request.prototype._captureMetaAndStats = function (err, result) {
89 | var self = this;
90 | var meta = (err && err.meta) || (result && result.meta);
91 | if (meta) {
92 | self.options._serviceMeta.push(meta);
93 | }
94 | var statsCollector = self.options.statsCollector;
95 | if (typeof statsCollector === 'function') {
96 | var stats = {
97 | resource: self.resource,
98 | operation: self.operation,
99 | params: self._params,
100 | statusCode: err ? err.statusCode : 200,
101 | err: err,
102 | time: Date.now() - self._startTime,
103 | };
104 | statsCollector(stats);
105 | }
106 | };
107 |
108 | Request.prototype._send = function () {
109 | if (this._request) {
110 | return this._request;
111 | }
112 |
113 | this._startTime = Date.now();
114 | this._request = httpRequest(normalizeOptions(this));
115 | var captureMetaAndStats = this._captureMetaAndStats.bind(this);
116 |
117 | this._request.then(
118 | function (result) {
119 | captureMetaAndStats(null, result);
120 | return result;
121 | },
122 | function (err) {
123 | captureMetaAndStats(err);
124 | throw err;
125 | },
126 | );
127 |
128 | return this._request;
129 | };
130 |
131 | Request.prototype.then = function (resolve, reject) {
132 | return this._send().then(resolve, reject);
133 | };
134 |
135 | Request.prototype.catch = function (reject) {
136 | return this._send().catch(reject);
137 | };
138 |
139 | Request.prototype.abort = function () {
140 | return this._request.abort();
141 | };
142 |
143 | /**
144 | * Execute this fetcher request and call callback.
145 | * @method end
146 | * @memberof Request
147 | * @param {Fetcher~fetcherCallback} callback callback invoked when fetcher/service is complete.
148 | * @async
149 | */
150 | Request.prototype.end = function (callback) {
151 | if (!arguments.length) {
152 | console.warn(
153 | 'You called .end() without a callback. This will become an error in the future. Use .then() instead.',
154 | );
155 | }
156 |
157 | this._send();
158 |
159 | if (callback) {
160 | this._request.then(
161 | function (result) {
162 | callback(null, result && result.data, result && result.meta);
163 | },
164 | function (err) {
165 | callback(err);
166 | },
167 | );
168 | }
169 |
170 | return this._request;
171 | };
172 |
173 | /**
174 | * Fetcher class for the client. Provides CRUD methods.
175 | * @class FetcherClient
176 | * @param {Object} options configuration options for Fetcher
177 | * @param {String} [options.xhrPath="/api"] The path for requests
178 | * @param {Number} [options.xhrTimeout=3000] Timeout in milliseconds for all requests
179 | * @param {Boolean} [options.corsPath] Base CORS path in case CORS is enabled
180 | * @param {Object} [options.context] The context object that is propagated to all outgoing
181 | * requests as query params. It can contain current-session/context data that should
182 | * persist to all requests.
183 | * @param {Object} [options.contextPicker] The context picker for GET
184 | * and POST, they must be a string, a an array or function with
185 | * three arguments (value, key, object) to extract keys from
186 | * context.
187 | * @param {Function|String|String[]} [options.contextPicker.GET] GET context picker
188 | * @param {Function|String|String[]} [options.contextPicker.POST] POST context picker
189 | * @param {Function} [options.statsCollector] The function will be invoked with 1 argument:
190 | * the stats object, which contains resource, operation, params (request params),
191 | * statusCode, err, and time (elapsed time)
192 | */
193 | function Fetcher(options) {
194 | var opts = options || {};
195 | this._serviceMeta = [];
196 | this.options = {
197 | headers: opts.headers,
198 | xhrPath: opts.xhrPath || DEFAULT_PATH,
199 | xhrTimeout: opts.xhrTimeout || DEFAULT_TIMEOUT,
200 | corsPath: opts.corsPath,
201 | context: opts.context || {},
202 | contextPicker: opts.contextPicker || {},
203 | retry: opts.retry || null,
204 | statsCollector: opts.statsCollector,
205 | unsafeAllowRetry: Boolean(opts.unsafeAllowRetry),
206 | _serviceMeta: this._serviceMeta,
207 | };
208 | }
209 |
210 | Fetcher.prototype = {
211 | // ------------------------------------------------------------------
212 | // Data Access Wrapper Methods
213 | // ------------------------------------------------------------------
214 |
215 | /**
216 | * create operation (create as in CRUD).
217 | * @method create
218 | * @param {String} resource The resource name
219 | * @param {Object} params The parameters identify the resource, and along with information
220 | * carried in query and matrix parameters in typical REST API
221 | * @param {Object} body The JSON object that contains the resource data that is being created
222 | * @param {Object} clientConfig The "config" object for per-request config data.
223 | * @param {Function} callback callback convention is the same as Node.js
224 | * @static
225 | */
226 | create: function (resource, params, body, clientConfig, callback) {
227 | var request = new Request('create', resource, this.options);
228 | if (1 === arguments.length) {
229 | return request;
230 | }
231 | // TODO: Remove below this line in release after next
232 | if (typeof clientConfig === 'function') {
233 | callback = clientConfig;
234 | clientConfig = {};
235 | }
236 | return request
237 | .params(params)
238 | .body(body)
239 | .clientConfig(clientConfig)
240 | .end(callback);
241 | },
242 |
243 | /**
244 | * read operation (read as in CRUD).
245 | * @method read
246 | * @param {String} resource The resource name
247 | * @param {Object} params The parameters identify the resource, and along with information
248 | * carried in query and matrix parameters in typical REST API
249 | * @param {Object} clientConfig The "config" object for per-request config data.
250 | * @param {Function} callback callback convention is the same as Node.js
251 | * @static
252 | */
253 | read: function (resource, params, clientConfig, callback) {
254 | var request = new Request('read', resource, this.options);
255 | if (1 === arguments.length) {
256 | return request;
257 | }
258 | // TODO: Remove below this line in release after next
259 | if (typeof clientConfig === 'function') {
260 | callback = clientConfig;
261 | clientConfig = {};
262 | }
263 | return request.params(params).clientConfig(clientConfig).end(callback);
264 | },
265 |
266 | /**
267 | * update operation (update as in CRUD).
268 | * @method update
269 | * @param {String} resource The resource name
270 | * @param {Object} params The parameters identify the resource, and along with information
271 | * carried in query and matrix parameters in typical REST API
272 | * @param {Object} body The JSON object that contains the resource data that is being updated
273 | * @param {Object} clientConfig The "config" object for per-request config data.
274 | * @param {Function} callback callback convention is the same as Node.js
275 | * @static
276 | */
277 | update: function (resource, params, body, clientConfig, callback) {
278 | var request = new Request('update', resource, this.options);
279 | if (1 === arguments.length) {
280 | return request;
281 | }
282 | // TODO: Remove below this line in release after next
283 | if (typeof clientConfig === 'function') {
284 | callback = clientConfig;
285 | clientConfig = {};
286 | }
287 | return request
288 | .params(params)
289 | .body(body)
290 | .clientConfig(clientConfig)
291 | .end(callback);
292 | },
293 |
294 | /**
295 | * delete operation (delete as in CRUD).
296 | * @method delete
297 | * @param {String} resource The resource name
298 | * @param {Object} params The parameters identify the resource, and along with information
299 | * carried in query and matrix parameters in typical REST API
300 | * @param {Object} clientConfig The "config" object for per-request config data.
301 | * @param {Function} callback callback convention is the same as Node.js
302 | * @static
303 | */
304 | delete: function (resource, params, clientConfig, callback) {
305 | var request = new Request('delete', resource, this.options);
306 | if (1 === arguments.length) {
307 | return request;
308 | }
309 | // TODO: Remove below this line in release after next
310 | if (typeof clientConfig === 'function') {
311 | callback = clientConfig;
312 | clientConfig = {};
313 | }
314 | return request.params(params).clientConfig(clientConfig).end(callback);
315 | },
316 |
317 | /**
318 | * Update options
319 | * @method updateOptions
320 | */
321 | updateOptions: function (options) {
322 | var self = this;
323 | var contextPicker = {};
324 | if (this.options.contextPicker && options.contextPicker) {
325 | ['GET', 'POST'].forEach(function (method) {
326 | var oldPicker = self.options.contextPicker[method];
327 | var newPicker = options.contextPicker[method];
328 |
329 | if (Array.isArray(oldPicker) && Array.isArray(newPicker)) {
330 | contextPicker[method] = [].concat(oldPicker, newPicker);
331 | } else if (oldPicker || newPicker) {
332 | var picker = newPicker || oldPicker;
333 | contextPicker[method] = Array.isArray(picker)
334 | ? [].concat(picker)
335 | : picker;
336 | }
337 | });
338 | } else {
339 | contextPicker = Object.assign(
340 | {},
341 | this.options.contextPicker,
342 | options.contextPicker,
343 | );
344 | }
345 |
346 | this.options = Object.assign({}, this.options, options, {
347 | context: Object.assign({}, this.options.context, options.context),
348 | contextPicker: contextPicker,
349 | headers: Object.assign({}, this.options.headers, options.headers),
350 | });
351 | },
352 |
353 | /**
354 | * get the serviceMeta array.
355 | * The array contains all requests meta returned in this session
356 | * with the 0 index being the first call.
357 | * @method getServiceMeta
358 | * @return {Array} array of metadata returned by each service call
359 | */
360 | getServiceMeta: function () {
361 | return this._serviceMeta;
362 | },
363 | };
364 |
365 | module.exports = Fetcher;
366 |
--------------------------------------------------------------------------------
/tests/unit/libs/util/httpRequest.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2015, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 |
6 | 'use strict';
7 |
8 | const fetchMock = require('fetch-mock');
9 | const { expect } = require('chai');
10 | const sinon = require('sinon');
11 | const FetchrError = require('../../../../libs/util/FetchrError');
12 | const httpRequest = require('../../../../libs/util/httpRequest');
13 |
14 | const contentTypeHeader = { ['Content-Type']: 'application/json' };
15 | const customHeader = { 'X-Foo': 'foo' };
16 | const requestedWithHeader = { ['X-Requested-With']: 'XMLHttpRequest' };
17 |
18 | const defaultRetry = {
19 | interval: 200,
20 | maxRetries: 0,
21 | retryOnPost: false,
22 | statusCodes: [0, 408, 999],
23 | };
24 |
25 | const baseConfig = {
26 | credentials: 'same-origin',
27 | body: undefined,
28 | headers: {
29 | ...customHeader,
30 | ...requestedWithHeader,
31 | },
32 | method: 'GET',
33 | retry: defaultRetry,
34 | timeout: 3000,
35 | url: '/url',
36 | };
37 |
38 | const GETConfig = {
39 | ...baseConfig,
40 | method: 'GET',
41 | };
42 |
43 | const corsGETConfig = {
44 | ...GETConfig,
45 | credentials: 'include',
46 | headers: customHeader,
47 | };
48 |
49 | const POSTConfig = {
50 | ...baseConfig,
51 | body: JSON.stringify({ data: 'data' }),
52 | method: 'POST',
53 | headers: {
54 | ...contentTypeHeader,
55 | ...customHeader,
56 | ...requestedWithHeader,
57 | },
58 | };
59 |
60 | const corsPOSTConfig = {
61 | ...POSTConfig,
62 | headers: {
63 | ...contentTypeHeader,
64 | ...customHeader,
65 | },
66 | };
67 |
68 | describe('Client HTTP', function () {
69 | let responseStatus;
70 | let mockBody;
71 |
72 | after(function () {
73 | fetchMock.reset();
74 | });
75 |
76 | afterEach(function () {
77 | fetchMock.resetHistory();
78 | fetchMock.resetBehavior();
79 | });
80 |
81 | describe('#Successful requests', function () {
82 | beforeEach(function () {
83 | responseStatus = 200;
84 | mockBody = { data: 'BODY' };
85 | });
86 |
87 | it('GET', function () {
88 | fetchMock.get('/url', {
89 | body: mockBody,
90 | status: responseStatus,
91 | });
92 |
93 | return httpRequest(GETConfig).then((response) => {
94 | expect(fetchMock.calls()).to.have.lengthOf(1);
95 | const options = fetchMock.lastCall().request;
96 | expect(options.url).to.equal('/url');
97 | expect(options.headers.get('X-Requested-With')).to.equal(
98 | 'XMLHttpRequest',
99 | );
100 | expect(options.headers.get('X-Foo')).to.equal('foo');
101 | expect(options.method).to.equal('GET');
102 | expect(response).to.deep.equal(mockBody);
103 | });
104 | });
105 |
106 | it('POST', function () {
107 | fetchMock.post('/url', {
108 | body: mockBody,
109 | status: responseStatus,
110 | });
111 |
112 | return httpRequest(POSTConfig).then(() => {
113 | expect(fetchMock.calls()).to.have.lengthOf(1);
114 | const options = fetchMock.lastCall().request;
115 | expect(options.url).to.equal('/url');
116 | expect(options.headers.get('X-Requested-With')).to.equal(
117 | 'XMLHttpRequest',
118 | );
119 | expect(options.headers.get('X-Foo')).to.equal('foo');
120 | expect(options.method).to.equal('POST');
121 | expect(options.body.toString()).to.equal('{"data":"data"}');
122 | });
123 | });
124 | });
125 |
126 | describe('#Successful CORS requests', function () {
127 | beforeEach(function () {
128 | responseStatus = 200;
129 | mockBody = { data: 'BODY' };
130 | sinon.spy(global, 'Request');
131 | });
132 |
133 | afterEach(() => {
134 | Request.restore();
135 | });
136 |
137 | it('GET', function () {
138 | fetchMock.get('/url', {
139 | body: mockBody,
140 | status: responseStatus,
141 | });
142 |
143 | return httpRequest(corsGETConfig).then((response) => {
144 | expect(fetchMock.calls()).to.have.lengthOf(1);
145 | const options = fetchMock.lastCall().request;
146 | expect(options.url).to.equal('/url');
147 | expect(options.headers).to.not.have.property(
148 | 'X-Requested-With',
149 | );
150 | expect(options.headers.get('X-Foo')).to.equal('foo');
151 | expect(options.method).to.equal('GET');
152 | expect(response).to.deep.equal(mockBody);
153 |
154 | sinon.assert.calledWith(
155 | Request,
156 | sinon.match.string,
157 | sinon.match({ credentials: 'include' }),
158 | );
159 | });
160 | });
161 |
162 | it('POST', function () {
163 | fetchMock.post('/url', {
164 | body: mockBody,
165 | status: responseStatus,
166 | });
167 |
168 | return httpRequest(corsPOSTConfig).then(() => {
169 | expect(fetchMock.calls()).to.have.lengthOf(1);
170 | const options = fetchMock.lastCall().request;
171 | expect(options.url).to.equal('/url');
172 | expect(options.headers).to.not.have.property(
173 | 'X-Requested-With',
174 | );
175 | expect(options.headers.get('X-Foo')).to.equal('foo');
176 | expect(options.method).to.equal('POST');
177 | expect(options.body.toString()).to.eql('{"data":"data"}');
178 |
179 | sinon.assert.calledWith(
180 | Request,
181 | sinon.match.string,
182 | sinon.match({ credentials: 'same-origin' }),
183 | );
184 | });
185 | });
186 | });
187 |
188 | describe('#400 requests', function () {
189 | beforeEach(function () {
190 | responseStatus = 400;
191 | });
192 |
193 | it('GET with empty response', function () {
194 | mockBody = '';
195 | fetchMock.get('/url', {
196 | body: mockBody,
197 | status: responseStatus,
198 | });
199 |
200 | return httpRequest(GETConfig).catch((err) => {
201 | expect(err.message).to.equal('');
202 | expect(err.statusCode).to.equal(400);
203 | expect(err.body).to.equal('');
204 | });
205 | });
206 |
207 | it('GET with JSON response containing message attribute', function () {
208 | mockBody = '{"message":"some body content"}';
209 | fetchMock.get('/url', {
210 | body: mockBody,
211 | status: responseStatus,
212 | });
213 |
214 | return httpRequest(GETConfig).catch((err) => {
215 | expect(err.message).to.equal('some body content');
216 | expect(err.statusCode).to.equal(400);
217 | expect(err.body).to.deep.equal({
218 | message: 'some body content',
219 | });
220 | });
221 | });
222 |
223 | it('GET with JSON response not containing message attribute', function () {
224 | mockBody = '{"other":"some body content"}';
225 | fetchMock.get('/url', {
226 | body: mockBody,
227 | status: responseStatus,
228 | });
229 |
230 | return httpRequest(GETConfig).catch((err) => {
231 | expect(err.message).to.equal(mockBody);
232 | expect(err.statusCode).to.equal(400);
233 | expect(err.body).to.deep.equal({
234 | other: 'some body content',
235 | });
236 | });
237 | });
238 |
239 | // Need to test plain text response
240 | // as some servers (e.g. node running in IIS)
241 | // may remove body content
242 | // and replace it with 'Bad Request'
243 | // if not configured to allow content throughput
244 | it('GET with plain text', function () {
245 | mockBody = 'Bad Request';
246 | fetchMock.get('/url', {
247 | body: mockBody,
248 | status: responseStatus,
249 | });
250 |
251 | return httpRequest(GETConfig).catch((err) => {
252 | expect(err.message).to.equal(mockBody);
253 | expect(err.statusCode).to.equal(400);
254 | expect(err.body).to.equal(mockBody);
255 | });
256 | });
257 | });
258 |
259 | describe('#Retry', function () {
260 | beforeEach(function () {
261 | mockBody = 'BODY';
262 | responseStatus = 408;
263 | });
264 |
265 | const expectRequestsToBeEqual = (req1, req2) => {
266 | expect(req1.request.url).to.equal(req2.request.url);
267 | expect(req1.request.method).to.equal(req2.request.method);
268 | expect(
269 | Object.fromEntries(req1.request.headers.entries()),
270 | ).to.deep.equal(Object.fromEntries(req2.request.headers.entries()));
271 | expect(req1.request.body).to.equal(req2.request.body);
272 | };
273 |
274 | it('GET with no retry', function () {
275 | fetchMock.get('/url', {
276 | body: mockBody,
277 | status: responseStatus,
278 | });
279 |
280 | return httpRequest(GETConfig).catch((err) => {
281 | const options = fetchMock.lastCall().request;
282 | expect(fetchMock.calls()).to.have.lengthOf(1);
283 | expect(options.url).to.equal('/url');
284 | expect(options.headers.get('X-Requested-With')).to.equal(
285 | 'XMLHttpRequest',
286 | );
287 | expect(options.headers.get('X-Foo')).to.equal('foo');
288 | expect(options.method).to.equal('GET');
289 | expect(err.message).to.equal('BODY');
290 | expect(err.statusCode).to.equal(408);
291 | expect(err.body).to.equal('BODY');
292 | });
293 | });
294 |
295 | it('GET with retry', function () {
296 | fetchMock.get('/url', {
297 | body: mockBody,
298 | status: 429,
299 | });
300 |
301 | const config = {
302 | ...GETConfig,
303 | retry: {
304 | ...defaultRetry,
305 | interval: 10,
306 | maxRetries: 2,
307 | statusCodes: [429],
308 | },
309 | };
310 |
311 | return httpRequest(config).catch((err) => {
312 | expect(fetchMock.calls()).to.have.lengthOf(3);
313 | const options = fetchMock.lastCall().request;
314 | expect(options.url).to.equal('/url');
315 | expect(options.headers.get('X-Requested-With')).to.equal(
316 | 'XMLHttpRequest',
317 | );
318 | expect(options.headers.get('X-Foo')).to.equal('foo');
319 | expect(options.method).to.equal('GET');
320 | expect(err.message).to.equal('BODY');
321 | expect(err.statusCode).to.equal(429);
322 | expect(err.body).to.equal('BODY');
323 |
324 | const [req1, req2, req3] = fetchMock.calls();
325 | expectRequestsToBeEqual(req1, req2);
326 | expectRequestsToBeEqual(req1, req3);
327 | });
328 | });
329 |
330 | it('GET with retry and custom status code', function () {
331 | responseStatus = 502;
332 | fetchMock.get('/url', {
333 | body: mockBody,
334 | status: responseStatus,
335 | });
336 |
337 | const config = {
338 | ...GETConfig,
339 | retry: {
340 | ...defaultRetry,
341 | interval: 20,
342 | maxRetries: 1,
343 | statusCodes: [502],
344 | },
345 | };
346 |
347 | return httpRequest(config).catch(() => {
348 | expect(fetchMock.calls()).to.have.lengthOf(2);
349 |
350 | const [req1, req2] = fetchMock.calls();
351 | expectRequestsToBeEqual(req1, req2);
352 | });
353 | });
354 |
355 | it('does not retry user aborted requests', function () {
356 | fetchMock.get('/url', {
357 | body: mockBody,
358 | status: 200,
359 | });
360 |
361 | const config = {
362 | ...GETConfig,
363 | retry: defaultRetry,
364 | };
365 |
366 | const request = httpRequest(config);
367 |
368 | request.abort();
369 |
370 | return request.catch((err) => {
371 | expect(fetchMock.calls()).to.have.lengthOf(1);
372 | expect(err.statusCode).to.equal(0);
373 | expect(err.reason).to.equal(FetchrError.ABORT);
374 | });
375 | });
376 | });
377 |
378 | describe('#Timeout', function () {
379 | beforeEach(function () {
380 | sinon.spy(global, 'setTimeout');
381 | responseStatus = 200;
382 | mockBody = {};
383 | });
384 |
385 | afterEach(() => {
386 | setTimeout.restore();
387 | });
388 |
389 | it('should use xhrTimeout for GET', function () {
390 | fetchMock.get('/url', {
391 | body: mockBody,
392 | status: responseStatus,
393 | });
394 |
395 | return httpRequest(GETConfig).then(() => {
396 | sinon.assert.calledWith(setTimeout, sinon.match.func, 3000);
397 | });
398 | });
399 |
400 | it('should use xhrTimeout for POST', function () {
401 | fetchMock.post('/url', {
402 | body: mockBody,
403 | status: responseStatus,
404 | });
405 |
406 | return httpRequest(POSTConfig).then(() => {
407 | sinon.assert.calledWith(setTimeout, sinon.match.func, 3000);
408 | });
409 | });
410 | });
411 |
412 | describe('Other failure scenarios', function () {
413 | it('can handle errors from fetch itself', function () {
414 | responseStatus = 0;
415 | fetchMock.get('/url', {
416 | throws: new Error('AnyError'),
417 | status: responseStatus,
418 | });
419 |
420 | const config = {
421 | ...GETConfig,
422 | timeout: 42,
423 | };
424 |
425 | return httpRequest(config).catch((err) => {
426 | expect(err.message).to.equal('AnyError');
427 | expect(err.timeout).to.equal(42);
428 | expect(err.url).to.equal('/url');
429 | });
430 | });
431 |
432 | it('can handle OK responses with bad JSON', function () {
433 | fetchMock.get('/url', {
434 | status: 200,
435 | body: 'Hello World!',
436 | });
437 |
438 | return httpRequest(GETConfig).catch((err) => {
439 | expect(err.statusCode).to.equal(200);
440 | expect(err.message).to.equal(
441 | 'Cannot parse response into a JSON object',
442 | );
443 | });
444 | });
445 | });
446 |
447 | describe('Promise Support', () => {
448 | it('always returns the response if resolved multiple times', async function () {
449 | const body = { data: 'BODY' };
450 |
451 | fetchMock.get('/url', { body, status: responseStatus });
452 |
453 | const request = httpRequest(GETConfig);
454 |
455 | expect(await request).to.deep.equal(body);
456 | expect(await request).to.deep.equal(body);
457 | });
458 |
459 | it('works with Promise.all', function () {
460 | const body = { data: 'BODY' };
461 |
462 | fetchMock.get('/url', { body, status: responseStatus });
463 |
464 | const request = httpRequest(GETConfig);
465 |
466 | return Promise.all([request]).then(([result]) => {
467 | expect(result).to.deep.equal(body);
468 | });
469 | });
470 | });
471 | });
472 |
--------------------------------------------------------------------------------
/tests/util/testCrud.js:
--------------------------------------------------------------------------------
1 | const expect = require('chai').expect;
2 | const defaultOptions = require('./defaultOptions');
3 | const resource = defaultOptions.resource;
4 | const invalidResource = 'invalid_resource';
5 | const mockErrorService = require('../mock/MockErrorService');
6 | const mockNoopService = require('../mock/MockNoopService');
7 |
8 | module.exports = function testCrud({
9 | params = defaultOptions.params,
10 | body = defaultOptions.body,
11 | config = defaultOptions.config,
12 | callback = defaultOptions.callback,
13 | resolve = defaultOptions.resolve,
14 | reject = defaultOptions.reject,
15 | disableNoConfigTests = false,
16 | isServer = false,
17 | }) {
18 | describe('CRUD Interface', function () {
19 | describe('should work superagent style', function () {
20 | describe('with callbacks', function () {
21 | it('should handle CREATE', function (done) {
22 | const operation = 'create';
23 | this.fetcher[operation](resource)
24 | .params(params)
25 | .body(body)
26 | .clientConfig(config)
27 | .end(callback(operation, done));
28 | });
29 |
30 | it('should handle READ', function (done) {
31 | const operation = 'read';
32 | this.fetcher[operation](resource)
33 | .params(params)
34 | .clientConfig(config)
35 | .end(callback(operation, done));
36 | });
37 |
38 | it('should handle UPDATE', function (done) {
39 | const operation = 'update';
40 | this.fetcher[operation](resource)
41 | .params(params)
42 | .body(body)
43 | .clientConfig(config)
44 | .end(callback(operation, done));
45 | });
46 |
47 | it('should handle DELETE', function (done) {
48 | const operation = 'delete';
49 | this.fetcher[operation](resource)
50 | .params(params)
51 | .clientConfig(config)
52 | .end(callback(operation, done));
53 | });
54 |
55 | it('should throw if no resource is given', function () {
56 | expect(this.fetcher.read.bind(this.fetcher)).to.throw(
57 | 'Resource is required for a fetcher request',
58 | );
59 | });
60 | });
61 |
62 | describe('with Promises', function () {
63 | function denySuccess(done) {
64 | return function () {
65 | done(new Error('This operation should have failed'));
66 | };
67 | }
68 |
69 | function allowFailure(done) {
70 | return function (err) {
71 | expect(err.name).to.equal('FetchrError');
72 | expect(err.message).to.exist;
73 | done();
74 | };
75 | }
76 |
77 | it('should handle CREATE', function (done) {
78 | const operation = 'create';
79 | this.fetcher[operation](resource)
80 | .params(params)
81 | .body(body)
82 | .clientConfig(config)
83 | .then(
84 | resolve(operation, done),
85 | reject(operation, done),
86 | );
87 | });
88 |
89 | it('should handle READ', function (done) {
90 | const operation = 'read';
91 | this.fetcher[operation](resource)
92 | .params(params)
93 | .clientConfig(config)
94 | .then(
95 | resolve(operation, done),
96 | reject(operation, done),
97 | );
98 | });
99 |
100 | it('should handle UPDATE', function (done) {
101 | const operation = 'update';
102 | this.fetcher[operation](resource)
103 | .params(params)
104 | .body(body)
105 | .clientConfig(config)
106 | .then(
107 | resolve(operation, done),
108 | reject(operation, done),
109 | );
110 | });
111 |
112 | it('should handle DELETE', function (done) {
113 | const operation = 'delete';
114 | this.fetcher[operation](resource)
115 | .params(params)
116 | .clientConfig(config)
117 | .then(
118 | resolve(operation, done),
119 | reject(operation, done),
120 | );
121 | });
122 |
123 | it('should reject a CREATE promise on invalid resource', function (done) {
124 | const operation = 'create';
125 | this.fetcher[operation](invalidResource)
126 | .params(params)
127 | .body(body)
128 | .clientConfig(config)
129 | .then(denySuccess(done), allowFailure(done));
130 | });
131 |
132 | it('should reject a READ promise on invalid resource', function (done) {
133 | const operation = 'read';
134 | this.fetcher[operation](invalidResource)
135 | .params(params)
136 | .clientConfig(config)
137 | .then(denySuccess(done), allowFailure(done));
138 | });
139 |
140 | it('should reject a UPDATE promise on invalid resource', function (done) {
141 | const operation = 'update';
142 | this.fetcher[operation](invalidResource)
143 | .params(params)
144 | .body(body)
145 | .clientConfig(config)
146 | .then(denySuccess(done), allowFailure(done));
147 | });
148 |
149 | it('should reject a DELETE promise on invalid resource', function (done) {
150 | const operation = 'delete';
151 | this.fetcher[operation](invalidResource)
152 | .params(params)
153 | .clientConfig(config)
154 | .then(denySuccess(done), allowFailure(done));
155 | });
156 |
157 | it('should throw if no resource is given', function () {
158 | expect(this.fetcher.read.bind(this.fetcher)).to.throw(
159 | 'Resource is required for a fetcher request',
160 | );
161 | });
162 | });
163 | });
164 |
165 | describe('should be backwards compatible', function () {
166 | function denySuccess(done) {
167 | return function (err) {
168 | if (!err) {
169 | done(new Error('This operation should have failed'));
170 | } else {
171 | expect(err.name).to.equal('FetchrError');
172 | expect(err.message).to.exist;
173 | done();
174 | }
175 | };
176 | }
177 |
178 | // with config
179 | it('should handle CREATE', function (done) {
180 | const operation = 'create';
181 | this.fetcher[operation](
182 | resource,
183 | params,
184 | body,
185 | config,
186 | callback(operation, done),
187 | );
188 | });
189 |
190 | it('should handle READ', function (done) {
191 | const operation = 'read';
192 | this.fetcher[operation](
193 | resource,
194 | params,
195 | config,
196 | callback(operation, done),
197 | );
198 | });
199 |
200 | it('should handle UPDATE', function (done) {
201 | const operation = 'update';
202 | this.fetcher[operation](
203 | resource,
204 | params,
205 | body,
206 | config,
207 | callback(operation, done),
208 | );
209 | });
210 |
211 | it('should handle DELETE', function (done) {
212 | const operation = 'delete';
213 | this.fetcher[operation](
214 | resource,
215 | params,
216 | config,
217 | callback(operation, done),
218 | );
219 | });
220 |
221 | it('should throw catchable error on CREATE with invalid resource', function (done) {
222 | const operation = 'create';
223 | this.fetcher[operation](
224 | invalidResource,
225 | params,
226 | body,
227 | config,
228 | denySuccess(done),
229 | );
230 | });
231 |
232 | it('should throw catchable error on READ with invalid resource', function (done) {
233 | const operation = 'read';
234 | this.fetcher[operation](
235 | invalidResource,
236 | params,
237 | config,
238 | denySuccess(done),
239 | );
240 | });
241 |
242 | it('should throw catchable error on UPDATE with invalid resource', function (done) {
243 | const operation = 'update';
244 | this.fetcher[operation](
245 | invalidResource,
246 | params,
247 | body,
248 | config,
249 | denySuccess(done),
250 | );
251 | });
252 |
253 | it('should throw catchable error on DELETE with invalid resource', function (done) {
254 | const operation = 'delete';
255 | this.fetcher[operation](
256 | invalidResource,
257 | params,
258 | config,
259 | denySuccess(done),
260 | );
261 | });
262 |
263 | if (!disableNoConfigTests) {
264 | // without config
265 | // we have a feature flag to disable these tests because
266 | // it doesn't make sense to test a feature like CORS without being able to pass in a config
267 | it('should handle CREATE w/ no config', function (done) {
268 | const operation = 'create';
269 | this.fetcher[operation](
270 | resource,
271 | params,
272 | body,
273 | callback(operation, done),
274 | );
275 | });
276 |
277 | it('should handle READ w/ no config', function (done) {
278 | const operation = 'read';
279 | this.fetcher[operation](
280 | resource,
281 | params,
282 | callback(operation, done),
283 | );
284 | });
285 |
286 | it('should handle UPDATE w/ no config', function (done) {
287 | const operation = 'update';
288 | this.fetcher[operation](
289 | resource,
290 | params,
291 | body,
292 | callback(operation, done),
293 | );
294 | });
295 |
296 | it('should handle DELETE w/ no config', function (done) {
297 | const operation = 'delete';
298 | this.fetcher[operation](
299 | resource,
300 | params,
301 | callback(operation, done),
302 | );
303 | });
304 | }
305 | });
306 |
307 | it('should keep track of metadata in getServiceMeta', function (done) {
308 | const fetcher = this.fetcher;
309 | fetcher._serviceMeta.length = 0; // reset serviceMeta to empty array
310 | fetcher
311 | .read(resource)
312 | .params({
313 | ...params,
314 | meta: { headers: { 'x-foo': 'foo' } },
315 | })
316 | .clientConfig(config)
317 | .end(function (err, data, meta) {
318 | if (err) {
319 | done(err);
320 | }
321 | expect(meta).to.include.keys('headers');
322 | expect(meta.headers).to.include.keys('x-foo');
323 | expect(meta.headers['x-foo']).to.equal('foo');
324 | fetcher
325 | .read(resource)
326 | .params({
327 | ...params,
328 | meta: { headers: { 'x-bar': 'bar' } },
329 | })
330 | .clientConfig(config)
331 | .end(function (err, data, meta) {
332 | if (err) {
333 | done(err);
334 | }
335 | expect(meta).to.include.keys('headers');
336 | expect(meta.headers).to.include.keys('x-bar');
337 | expect(meta.headers['x-bar']).to.equal('bar');
338 | const serviceMeta = fetcher.getServiceMeta();
339 | expect(serviceMeta).to.have.length(2);
340 | expect(serviceMeta[0].headers).to.include.keys(
341 | 'x-foo',
342 | );
343 | expect(serviceMeta[0].headers['x-foo']).to.equal(
344 | 'foo',
345 | );
346 | expect(serviceMeta[1].headers).to.include.keys(
347 | 'x-bar',
348 | );
349 | expect(serviceMeta[1].headers['x-bar']).to.equal(
350 | 'bar',
351 | );
352 | done();
353 | });
354 | });
355 | });
356 |
357 | describe('should have serviceMeta data on error', function () {
358 | it('with callbacks', function (done) {
359 | const fetcher = this.fetcher;
360 | fetcher._serviceMeta.length = 0; // reset serviceMeta to empty array
361 | fetcher
362 | .read(mockErrorService.resource)
363 | .params({
364 | ...params,
365 | meta: { headers: { 'x-foo': 'foo' } },
366 | })
367 | .clientConfig(config)
368 | .end(function (err) {
369 | if (err) {
370 | const serviceMeta = fetcher.getServiceMeta();
371 | expect(serviceMeta).to.have.length(1);
372 | expect(serviceMeta[0]).to.include.keys('headers');
373 | expect(serviceMeta[0].headers).to.include.keys(
374 | 'x-foo',
375 | );
376 | expect(serviceMeta[0].headers['x-foo']).to.equal(
377 | 'foo',
378 | );
379 | done();
380 | }
381 | });
382 | });
383 |
384 | it('with Promises', function (done) {
385 | const fetcher = this.fetcher;
386 | fetcher._serviceMeta.length = 0; // reset serviceMeta to empty array
387 | fetcher
388 | .read(mockErrorService.resource)
389 | .params({
390 | ...params,
391 | meta: { headers: { 'x-foo': 'foo' } },
392 | })
393 | .clientConfig(config)
394 | .catch(function (err) {
395 | if (err) {
396 | const serviceMeta = fetcher.getServiceMeta();
397 | expect(serviceMeta).to.have.length(1);
398 | expect(serviceMeta[0]).to.include.keys('headers');
399 | expect(serviceMeta[0].headers).to.include.keys(
400 | 'x-foo',
401 | );
402 | expect(serviceMeta[0].headers['x-foo']).to.equal(
403 | 'foo',
404 | );
405 | done();
406 | }
407 | });
408 | });
409 | });
410 | });
411 |
412 | describe('should reject when resource does not implement operation', function () {
413 | function getErrorMessage(err) {
414 | return isServer
415 | ? err.message
416 | : JSON.parse(err.message).output.message;
417 | }
418 |
419 | it('with callback', function (done) {
420 | const fetcher = this.fetcher;
421 | fetcher
422 | .read(mockNoopService.resource)
423 | .clientConfig(config)
424 | .end(function (err) {
425 | const message = getErrorMessage(err);
426 | expect(err.name).to.equal('FetchrError');
427 | expect(message).to.equal(
428 | 'Operation "read" is not allowed for resource "mock_noop_service"',
429 | );
430 | done();
431 | });
432 | });
433 |
434 | it('with Promise', function (done) {
435 | const fetcher = this.fetcher;
436 | fetcher
437 | .read(mockNoopService.resource)
438 | .clientConfig(config)
439 | .catch(function (err) {
440 | const message = getErrorMessage(err);
441 | expect(err.name).to.equal('FetchrError');
442 | expect(message).to.equal(
443 | 'Operation "read" is not allowed for resource "mock_noop_service"',
444 | );
445 | done();
446 | });
447 | });
448 | });
449 | };
450 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > ⚠️ **DEPRECATED / END-OF-LIFE (EOL)** — `fetchr` will be unmaintained as of **2025-12-31**.
2 | > No new features, issues, PRs, or security fixes will be provided.
3 | > See the issue for more information - https://github.com/yahoo/fetchr/issues/576
4 |
5 | # Fetchr
6 |
7 | [](http://badge.fury.io/js/fetchr)
8 | 
9 | [](https://coveralls.io/r/yahoo/fetchr?branch=master)
10 |
11 | Universal data access layer for web applications.
12 |
13 | 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/fetch requests need to be made to the server which get forwarded to your service.
14 |
15 | 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.
16 |
17 | ## Install
18 |
19 | ```bash
20 | npm install fetchr --save
21 | ```
22 |
23 | _Important:_ when on browser, `Fetchr` relies fully on [`Fetch`](https://fetch.spec.whatwg.org/) API. If you need to support old browsers, you will need to install a polyfill as well (eg. https://github.com/github/fetch).
24 |
25 | ## Setup
26 |
27 | Follow the steps below to setup Fetchr properly. This assumes you are using the [Express](https://www.npmjs.com/package/express) framework.
28 |
29 | ### 1. Configure Server
30 |
31 | On the server side, add the Fetchr middleware into your express app at a custom API endpoint.
32 |
33 | 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.
34 |
35 | ```js
36 | import express from 'express';
37 | import Fetcher from 'fetchr';
38 | import bodyParser from 'body-parser';
39 | const app = express();
40 |
41 | // you need to use body-parser middleware before fetcher middleware
42 | app.use(bodyParser.json());
43 |
44 | app.use('/myCustomAPIEndpoint', Fetcher.middleware());
45 | ```
46 |
47 | ### 2. Configure Client
48 |
49 | On the client side, it is necessary for the `xhrPath` option to match the path where the middleware was mounted in the previous step
50 |
51 | `xhrPath` is an optional config property that allows you to customize the endpoint to your services, defaults to `/api`.
52 |
53 | ```js
54 | import Fetcher from 'fetchr';
55 | const fetcher = new Fetcher({
56 | xhrPath: '/myCustomAPIEndpoint',
57 | });
58 | ```
59 |
60 | ### 3. Register data services
61 |
62 | You will need to register any data services that you wish to use in
63 | your application. The interface for your service will be an object
64 | that must define a `resource` property and at least one
65 | [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)
66 | operation. The `resource` property will be used when you call one of the
67 | CRUD operations.
68 |
69 | ```js
70 | // app.js
71 | import Fetcher from 'fetchr';
72 | import myDataService from './dataService';
73 | Fetcher.registerService(myDataService);
74 | ```
75 |
76 | ```js
77 | // dataService.js
78 | export default {
79 | // resource is required
80 | resource: 'data_service',
81 | // at least one of the CRUD methods is required
82 | read: async function ({ req, resource, params, config }) {
83 | return { data: 'foo' };
84 | },
85 | // other methods
86 | // create: async function({ req, resource, params, body, config }) {},
87 | // update: async function({ req, resource, params, body, config }) {},
88 | // delete: async function({ req, resource, params, config }) {}
89 | };
90 | ```
91 |
92 | ### 4. Instantiating the Fetchr Class
93 |
94 | Data services might need access to each individual request, for example, to get the current logged in user's session.
95 | For this reason, Fetcher will have to be instantiated once per request.
96 |
97 | On the serverside, this requires fetcher to be instantiated per request, in express middleware.
98 | On the clientside, this only needs to happen on page load.
99 |
100 | ```js
101 | // app.js - server
102 | import express from 'express';
103 | import Fetcher from 'fetchr';
104 | import myDataService from './dataService';
105 | const app = express();
106 |
107 | // register the service
108 | Fetcher.registerService(myDataService);
109 |
110 | // register the middleware
111 | app.use('/myCustomAPIEndpoint', Fetcher.middleware());
112 |
113 | app.use(function (req, res, next) {
114 | // instantiated fetcher with access to req object
115 | const fetcher = new Fetcher({
116 | xhrPath: '/myCustomAPIEndpoint', // xhrPath will be ignored on the serverside fetcher instantiation
117 | req: req,
118 | });
119 |
120 | // perform read call to get data
121 | fetcher
122 | .read('data_service')
123 | .params({ id: 42 })
124 | .then(({ data, meta }) => {
125 | // handle data returned from data fetcher in this callback
126 | })
127 | .catch((err) => {
128 | // handle error
129 | });
130 | });
131 | ```
132 |
133 | ```js
134 | // app.js - client
135 | import Fetcher from 'fetchr';
136 | const fetcher = new Fetcher({
137 | xhrPath: '/myCustomAPIEndpoint', // xhrPath is REQUIRED on the clientside fetcher instantiation
138 | });
139 | fetcher
140 | .read('data_api_fetcher')
141 | .params({ id: 42 })
142 | .then(({ data, meta }) => {
143 | // handle data returned from data fetcher in this callback
144 | })
145 | .catch((err) => {
146 | // handle errors
147 | });
148 |
149 | // for create you can use the body() method to pass data
150 | fetcher
151 | .create('data_api_create')
152 | .body({ some: 'data' })
153 | .then(({ data, meta }) => {
154 | // handle data returned from data fetcher in this callback
155 | })
156 | .catch((err) => {
157 | // handle errors
158 | });
159 | ```
160 |
161 | ## Usage Examples
162 |
163 | See the [simple example](https://github.com/yahoo/fetchr/tree/master/examples/simple).
164 |
165 | ## Service Metadata
166 |
167 | Service calls on the client transparently become fetch requests.
168 | It is a good idea to set cache headers on common fetch calls.
169 | You can do so by providing a third parameter in your service's callback.
170 | If you want to look at what headers were set by the service you just called,
171 | simply inspect the third parameter in the callback.
172 |
173 | Note: If you're using promises, the metadata will be available on the `meta`
174 | property of the resolved value.
175 |
176 | ```js
177 | // dataService.js
178 | export default {
179 | resource: 'data_service',
180 | read: async function ({ req, resource, params, config }) {
181 | return {
182 | data: 'response', // business logic
183 | meta: {
184 | headers: {
185 | 'cache-control': 'public, max-age=3600',
186 | },
187 | statusCode: 200, // You can even provide a custom statusCode for the fetch response
188 | },
189 | };
190 | },
191 | };
192 | ```
193 |
194 | ```js
195 | fetcher
196 | .read('data_service')
197 | .params({id: ###})
198 | .then(({ data, meta }) {
199 | // data will be 'response'
200 | // meta will have the header and statusCode from above
201 | });
202 | ```
203 |
204 | There is a convenience method called `fetcher.getServiceMeta` on the fetchr instance.
205 | This method will return the metadata for all the calls that have happened so far
206 | in an array format.
207 | In the server, this will include all service calls for the current request.
208 | In the client, this will include all service calls for the current session.
209 |
210 | ## Updating Configuration
211 |
212 | Usually you instantiate fetcher with some default options for the entire browser session,
213 | but there might be cases where you want to update these options later in the same session.
214 |
215 | You can do that with the `updateOptions` method:
216 |
217 | ```js
218 | // Start
219 | const fetcher = new Fetcher({
220 | xhrPath: '/myCustomAPIEndpoint',
221 | xhrTimeout: 2000,
222 | });
223 |
224 | // Later, you may want to update the xhrTimeout
225 | fetcher.updateOptions({
226 | xhrTimeout: 4000,
227 | });
228 | ```
229 |
230 | ## Error Handling
231 |
232 | When an error occurs in your Fetchr CRUD method, you should throw an error object. The error object should contain a `statusCode` (default 500) and `output` property that contains a JSON serializable object which will be sent to the client.
233 |
234 | ```js
235 | export default {
236 | resource: 'FooService',
237 | read: async function create(req, resource, params, configs) {
238 | const err = new Error('it failed');
239 | err.statusCode = 404;
240 | err.output = { message: 'Not found', more: 'meta data' };
241 | err.meta = { foo: 'bar' };
242 | throw err;
243 | },
244 | };
245 | ```
246 |
247 | And in your service call:
248 |
249 | ```js
250 | fetcher
251 | .read('someData')
252 | .params({ id: '42' })
253 | .catch((err) => {
254 | // err instanceof FetchrError -> true
255 | // err.message -> "Not found"
256 | // err.meta -> { foo: 'bar' }
257 | // err.name = 'FetchrError'
258 | // err.output -> { message: "Not found", more: "meta data" }
259 | // err.rawRequest -> { headers: {}, method: 'GET', url: '/api/someData' }
260 | // err.reason -> BAD_HTTP_STATUS | BAD_JSON | TIMEOUT | ABORT | UNKNOWN
261 | // err.statusCode -> 404
262 | // err.timeout -> 3000
263 | // err.url -> '/api/someData'
264 | });
265 | ```
266 |
267 | ## Abort support
268 |
269 | An object with an `abort` method is returned when creating fetchr requests on client.
270 | This is useful if you want to abort a request before it is completed.
271 |
272 | ```js
273 | const req = fetcher
274 | .read('someData')
275 | .params({ id: 42 })
276 | .catch((err) => {
277 | // err.reason will be ABORT
278 | });
279 |
280 | req.abort();
281 | ```
282 |
283 | ## Timeouts
284 |
285 | `xhrTimeout` is an optional config property that allows you to set timeout (in ms) for all clientside requests, defaults to `3000`.
286 | On the clientside, xhrPath and xhrTimeout will be used for all requests.
287 | On the serverside, xhrPath and xhrTimeout are not needed and are ignored.
288 |
289 | ```js
290 | import Fetcher from 'fetchr';
291 | const fetcher = new Fetcher({
292 | xhrPath: '/myCustomAPIEndpoint',
293 | xhrTimeout: 4000,
294 | });
295 | ```
296 |
297 | If you want to set a timeout per request you can call `clientConfig` with a `timeout` property:
298 |
299 | ```js
300 | fetcher
301 | .read('someData')
302 | .params({ id: 42 })
303 | .clientConfig({ timeout: 5000 }) // wait 5 seconds for this request before timing out
304 | .catch((err) => {
305 | // err.reason will be TIMEOUT
306 | });
307 | ```
308 |
309 | ## Params Processing
310 |
311 | For some applications, there may be a situation where you need to process the service params passed in the request before they are sent to the actual service. Typically, you would process them in the service itself. However, if you need to perform processing across many services (i.e. sanitization for security), then you can use the `paramsProcessor` option.
312 |
313 | `paramsProcessor` is a function that is passed into the `Fetcher.middleware` method. It is passed three arguments, the request object, the serviceInfo object, and the service params object. The `paramsProcessor` function can then modify the service params if needed.
314 |
315 | Here is an example:
316 |
317 | ```js
318 | /**
319 | Using the app.js from above, you can modify the Fetcher.middleware
320 | method to pass in the paramsProcessor function.
321 | */
322 | app.use(
323 | '/myCustomAPIEndpoint',
324 | Fetcher.middleware({
325 | paramsProcessor: function (req, serviceInfo, params) {
326 | console.log(serviceInfo.resource, serviceInfo.operation);
327 | return Object.assign({ foo: 'fillDefaultValueForFoo' }, params);
328 | },
329 | }),
330 | );
331 | ```
332 |
333 | ## Response Formatting
334 |
335 | For some applications, there may be a situation where you need to modify the response before it is passed to the client. Typically, you would apply your modifications in the service itself. However, if you need to modify the responses across many services (i.e. add debug information), then you can use the `responseFormatter` option.
336 |
337 | `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.
338 |
339 | Take a look at the example below:
340 |
341 | ```js
342 | /**
343 | Using the app.js from above, you can modify the Fetcher.middleware
344 | method to pass in the responseFormatter function.
345 | */
346 | app.use(
347 | '/myCustomAPIEndpoint',
348 | Fetcher.middleware({
349 | responseFormatter: function (req, res, data) {
350 | data.debug = 'some debug information';
351 | return data;
352 | },
353 | }),
354 | );
355 | ```
356 |
357 | Now when an request is performed, your response will contain the `debug` property added above.
358 |
359 | ## CORS Support
360 |
361 | 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.
362 |
363 | For example:
364 |
365 | ```js
366 | import Fetcher from 'fetchr';
367 | const fetcher = new Fetcher({
368 | corsPath: 'http://www.foo.com',
369 | xhrPath: '/fooProxy',
370 | });
371 | fetcher.read('service').params({ foo: 1 }).clientConfig({ cors: true });
372 | ```
373 |
374 | Additionally, you can also customize how the GET URL is constructed by passing in the `constructGetUri` property when you execute your `read` call:
375 |
376 | ```js
377 | import qs from 'qs';
378 | function customConstructGetUri(uri, resource, params, config) {
379 | // this refers to the Fetcher object itself that this function is invoked with.
380 | if (config.cors) {
381 | return uri + '/' + resource + '?' + qs.stringify(this.context);
382 | }
383 | // Return `falsy` value will result in `fetcher` using its internal path construction instead.
384 | }
385 |
386 | import Fetcher from 'fetchr';
387 | const fetcher = new Fetcher({
388 | corsPath: 'http://www.foo.com',
389 | xhrPath: '/fooProxy',
390 | });
391 | fetcher.read('service').params({ foo: 1 }).clientConfig({
392 | cors: true,
393 | constructGetUri: customConstructGetUri,
394 | });
395 | ```
396 |
397 | ## CSRF Protection
398 |
399 | You can protect your Fetchr middleware paths from CSRF attacks by adding a middleware in front of it:
400 |
401 | `app.use('/myCustomAPIEndpoint', csrf(), Fetcher.middleware());`
402 |
403 | You could use https://github.com/expressjs/csurf for this as an example.
404 |
405 | Next you need to make sure that the CSRF token is being sent with our 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:
406 |
407 | ```js
408 | const fetcher = new Fetcher({
409 | xhrPath: '/myCustomAPIEndpoint', // xhrPath is REQUIRED on the clientside fetcher instantiation
410 | context: {
411 | // These context values are persisted with client calls as query params
412 | _csrf: 'Ax89D94j',
413 | },
414 | });
415 | ```
416 |
417 | This `_csrf` will be sent in all client requests as a query parameter so that it can be validated on the server.
418 |
419 | ## Service Call Config
420 |
421 | When calling a Fetcher service you can pass an optional config object.
422 |
423 | When this call is made from the client, the config object is used to set some request options and can be used to override default options:
424 |
425 | ```js
426 | //app.js - client
427 | const config = {
428 | timeout: 6000, // Timeout (in ms) for each request
429 | unsafeAllowRetry: false, // for POST requests, whether to allow retrying this post
430 | };
431 |
432 | fetcher.read('service').params({ id: 1 }).clientConfig(config);
433 | ```
434 |
435 | For requests from the server, the config object is simply passed into the service being called.
436 |
437 | ## Retry
438 |
439 | You can set Fetchr to automatically retry failed requests by specifying a `retry` configuration in the global or in the request configuration:
440 |
441 | ```js
442 | // Globally
443 | const fetchr = new Fetchr({
444 | retry: { maxRetries: 2 },
445 | });
446 |
447 | // Per request
448 | fetchr.read('service').clientConfig({
449 | retry: { maxRetries: 1 },
450 | });
451 | ```
452 |
453 | With the above configuration, Fetchr will retry twice all requests
454 | that fail but only once when calling `read('service')`.
455 |
456 | You can further customize how the retry mechanism works. These are all
457 | settings and their default values:
458 |
459 | ```js
460 | const fetchr = new Fetchr({
461 | retry: {
462 | maxRetries: 2, // amount of retries after the first failed request
463 | interval: 200, // maximum interval between each request in ms (see note below)
464 | statusCodes: [0, 408], // response status code that triggers a retry (see note below)
465 | },
466 | unsafeAllowRetry: false, // allow unsafe operations to be retried (see note below)
467 | }
468 | ```
469 |
470 | **interval**
471 |
472 | The interval between each request respects the following formula, based on the exponential backoff and full jitter strategy published in [this AWS architecture blog post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/):
473 |
474 | ```js
475 | Math.random() * Math.pow(2, attempt) * interval;
476 | ```
477 |
478 | `attempt` is the number of the current retry attempt starting
479 | from 0. By default `interval` corresponds to 200ms.
480 |
481 | **statusCodes**
482 |
483 | For historical reasons, fetchr only retries 408 responses and no
484 | responses at all (for example, a network error, indicated by a status
485 | code 0). However, you might find useful to also retry on other codes
486 | as well (502, 503, 504 can be good candidates for an automatic
487 | retries).
488 |
489 | **unsafeAllowRetry**
490 |
491 | By default, Fetchr only retries `read` requests. This is done for
492 | safety reasons: reading twice an entry from a database is not as bad
493 | as creating an entry twice. But if your application or resource
494 | doesn't need this kind of protection, you can allow retries by setting
495 | `unsafeAllowRetry` to `true` and fetchr will retry all operations.
496 |
497 | ## Context Variables
498 |
499 | By Default, fetchr appends all context values to the request url as query params. `contextPicker` allows you to have greater control over which context variables get sent as query params depending on the request 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).
500 |
501 | `contextPicker` follows the same format as the `predicate` parameter in [`lodash/pickBy`](https://lodash.com/docs#pickBy) with two arguments: `(value, key)`.
502 |
503 | ```js
504 | const fetcher = new Fetcher({
505 | context: {
506 | // These context values are persisted with client calls as query params
507 | _csrf: 'Ax89D94j',
508 | device: 'desktop',
509 | },
510 | contextPicker: {
511 | GET: function (value, key) {
512 | // for example, if you don't enable CSRF protection for GET, you are able to ignore it with the url
513 | if (key === '_csrf') {
514 | return false;
515 | }
516 | return true;
517 | },
518 | // for other method e.g., POST, if you don't define the picker, it will pick the entire context object
519 | },
520 | });
521 |
522 | const fetcher = new Fetcher({
523 | context: {
524 | // These context values are persisted with client calls as query params
525 | _csrf: 'Ax89D94j',
526 | device: 'desktop',
527 | },
528 | contextPicker: {
529 | GET: ['device'], // predicate can be an array of strings
530 | },
531 | });
532 | ```
533 |
534 | ## Custom Request Headers
535 |
536 | When calling a Fetcher service you can add custom request headers.
537 |
538 | A request contains custom headers when you add `headers` option to 'clientConfig'.
539 |
540 | ```js
541 | const config = {
542 | headers: {
543 | 'X-VERSION': '1.0.0',
544 | },
545 | };
546 |
547 | fetcher.read('service').params({ id: 1 }).clientConfig(config);
548 | ```
549 |
550 | All requests contain custom headers when you add `headers` option to constructor arguments of 'Fetcher'.
551 |
552 | ```js
553 | import Fetcher from 'fetchr';
554 | const fetcher = new Fetcher({
555 | headers: {
556 | 'X-VERSION': '1.0.0',
557 | },
558 | });
559 | ```
560 |
561 | ## Stats Monitoring & Analysis
562 |
563 | To collect fetcher service's success/failure/latency stats, you can configure `statsCollector` for `Fetchr`. The `statsCollector` function will be invoked with one argumment: `stats`. The `stats` object will contain the following fields:
564 |
565 | - **resource:** The name of the resource for the request
566 | - **operation:** The name of the operation, `create|read|update|delete`
567 | - **params:** The params object for the resource
568 | - **statusCode:** The status code of the response
569 | - **err:** The error object of failed request; null if request was successful
570 | - **time:** The time spent for this request, in milliseconds
571 |
572 | ### Fetcher Instance
573 |
574 | ```js
575 | import Fetcher from 'fetchr';
576 | const fetcher = new Fetcher({
577 | xhrPath: '/myCustomAPIEndpoint',
578 | statsCollector: function (stats) {
579 | // just console logging as a naive example. there is a lot more you can do here,
580 | // like aggregating stats or filtering out stats you don't want to monitor
581 | console.log(
582 | 'Request for resource',
583 | stats.resource,
584 | 'with',
585 | stats.operation,
586 | 'returned statusCode:',
587 | stats.statusCode,
588 | ' within',
589 | stats.time,
590 | 'ms',
591 | );
592 | },
593 | });
594 | ```
595 |
596 | ### Server Middleware
597 |
598 | ```js
599 | app.use(
600 | '/myCustomAPIEndpoint',
601 | Fetcher.middleware({
602 | statsCollector: function (stats) {
603 | // just console logging as a naive example. there is a lot more you can do here,
604 | // like aggregating stats or filtering out stats you don't want to monitor
605 | console.log(
606 | 'Request for resource',
607 | stats.resource,
608 | 'with',
609 | stats.operation,
610 | 'returned statusCode:',
611 | stats.statusCode,
612 | ' within',
613 | stats.time,
614 | 'ms',
615 | );
616 | },
617 | }),
618 | );
619 | ```
620 |
621 | ## API
622 |
623 | - [Fetchr](https://github.com/yahoo/fetchr/blob/master/docs/fetchr.md)
624 |
625 | ## License
626 |
627 | This software is free to use under the Yahoo! Inc. BSD license.
628 | See the [LICENSE file][] for license text and copyright information.
629 |
630 | [license file]: https://github.com/yahoo/fetchr/blob/master/LICENSE.md
631 |
--------------------------------------------------------------------------------
/tests/functional/fetchr.test.js:
--------------------------------------------------------------------------------
1 | /* global Fetchr */
2 | const { expect } = require('chai');
3 | const puppeteer = require('puppeteer');
4 | const FetchrError = require('../../libs/util/FetchrError');
5 | const app = require('./app');
6 | const buildClient = require('./buildClient');
7 | const { itemsData } = require('./resources/item');
8 | const { retryToggle } = require('./resources/error');
9 |
10 | describe('client/server integration', () => {
11 | let browser;
12 | let server;
13 | let page;
14 |
15 | before(async function setupClient() {
16 | await buildClient();
17 | });
18 |
19 | before(function setupServer(done) {
20 | server = app.listen(3000, done);
21 | });
22 |
23 | before(async function setupBrowser() {
24 | browser = await puppeteer.launch({ headless: 'new' });
25 | page = await browser.newPage();
26 | await page.goto('http://localhost:3000');
27 | });
28 |
29 | after(async function shutdownBrowser() {
30 | await browser.close();
31 | });
32 |
33 | after(function shutdownServer(done) {
34 | server.close(done);
35 | });
36 |
37 | beforeEach(() => {
38 | retryToggle.error = true;
39 | });
40 |
41 | describe('CRUD', () => {
42 | it('can create item', async () => {
43 | const response = await page.evaluate(() => {
44 | const fetcher = new Fetchr();
45 | return fetcher.create(
46 | 'item',
47 | { id: '42' },
48 | { value: 'this is an item' },
49 | );
50 | });
51 |
52 | expect(itemsData).to.deep.equal({
53 | 42: {
54 | id: '42',
55 | value: 'this is an item',
56 | },
57 | });
58 | expect(response.data).to.deep.equal({
59 | id: '42',
60 | value: 'this is an item',
61 | });
62 | expect(response.meta).to.deep.equal({ statusCode: 201 });
63 | });
64 |
65 | it('can read one item', async () => {
66 | const response = await page.evaluate(() => {
67 | const fetcher = new Fetchr();
68 | return fetcher.read('item', { id: '42' });
69 | });
70 |
71 | expect(response.data).to.deep.equal({
72 | id: '42',
73 | value: 'this is an item',
74 | });
75 | expect(response.meta).to.deep.equal({
76 | statusCode: 200,
77 | });
78 | });
79 |
80 | it('can read many items', async () => {
81 | const response = await page.evaluate(() => {
82 | const fetcher = new Fetchr();
83 | return fetcher.read('item');
84 | });
85 |
86 | expect(response.data).to.deep.equal([
87 | {
88 | id: '42',
89 | value: 'this is an item',
90 | },
91 | ]);
92 | expect(response.meta).to.deep.equal({
93 | statusCode: 200,
94 | });
95 | });
96 |
97 | it('can update item', async () => {
98 | const response = await page.evaluate(() => {
99 | const fetcher = new Fetchr();
100 | return fetcher.update(
101 | 'item',
102 | { id: '42' },
103 | { value: 'this is an updated item' },
104 | );
105 | });
106 |
107 | expect(itemsData).to.deep.equal({
108 | 42: {
109 | id: '42',
110 | value: 'this is an updated item',
111 | },
112 | });
113 | expect(response.data).to.deep.equal({
114 | id: '42',
115 | value: 'this is an updated item',
116 | });
117 | expect(response.meta).to.deep.equal({ statusCode: 201 });
118 | });
119 |
120 | it('can delete item', async () => {
121 | const response = await page.evaluate(() => {
122 | const fetcher = new Fetchr();
123 | return fetcher.delete('item', { id: '42' });
124 | });
125 |
126 | expect(itemsData).to.deep.equal({});
127 | expect(response.data).to.deep.equal(null);
128 | expect(response.meta).to.deep.equal({ statusCode: 200 });
129 | });
130 | });
131 |
132 | describe('Promise support', () => {
133 | it('Always return the same value if resolved multiple times', async () => {
134 | const [id, value] = await page.evaluate(async () => {
135 | const fetcher = new Fetchr();
136 |
137 | await fetcher.create(
138 | 'item',
139 | { id: '42' },
140 | { value: 'this is an item' },
141 | );
142 |
143 | const request = fetcher.read('item', { id: '42' });
144 |
145 | return Promise.all([
146 | request.then(({ data }) => data.id),
147 | request.then(({ data }) => data.value),
148 | ]);
149 | });
150 |
151 | expect(id).to.equal('42');
152 | expect(value).to.equal('this is an item');
153 | });
154 |
155 | it('Works with Promise.all', async () => {
156 | const response = await page.evaluate(async () => {
157 | const fetcher = new Fetchr();
158 |
159 | await fetcher.create(
160 | 'item',
161 | { id: '42' },
162 | { value: 'this is an item' },
163 | );
164 |
165 | const promise = fetcher.read('item', { id: '42' });
166 |
167 | return Promise.all([promise])
168 | .then(([result]) => result)
169 | .catch((err) => err);
170 | });
171 |
172 | expect(response.data).to.deep.equal({
173 | id: '42',
174 | value: 'this is an item',
175 | });
176 | expect(response.meta).to.deep.equal({
177 | statusCode: 200,
178 | });
179 | });
180 | });
181 |
182 | describe('Error handling', () => {
183 | describe('GET', () => {
184 | it('can handle unconfigured server', async () => {
185 | const response = await page.evaluate(() => {
186 | const fetcher = new Fetchr({
187 | xhrPath: 'http://localhost:3001',
188 | });
189 | return fetcher.read('error').catch((err) => err);
190 | });
191 |
192 | expect(response).to.deep.equal({
193 | body: null,
194 | message: 'Failed to fetch',
195 | meta: null,
196 | name: 'FetchrError',
197 | output: null,
198 | rawRequest: {
199 | url: 'http://localhost:3001/error',
200 | method: 'GET',
201 | headers: { 'X-Requested-With': 'XMLHttpRequest' },
202 | },
203 | reason: FetchrError.UNKNOWN,
204 | statusCode: 0,
205 | timeout: 3000,
206 | url: 'http://localhost:3001/error',
207 | });
208 | });
209 |
210 | it('can handle service expected errors', async () => {
211 | const response = await page.evaluate(() => {
212 | const fetcher = new Fetchr();
213 | return fetcher.read('error').catch((err) => err);
214 | });
215 |
216 | expect(response).to.deep.equal({
217 | body: {
218 | output: { message: 'error' },
219 | meta: { foo: 'bar' },
220 | },
221 | message:
222 | '{"output":{"message":"error"},"meta":{"foo":"bar"}}',
223 | meta: { foo: 'bar' },
224 | name: 'FetchrError',
225 | output: { message: 'error' },
226 | rawRequest: {
227 | url: 'http://localhost:3000/api/error',
228 | method: 'GET',
229 | headers: { 'X-Requested-With': 'XMLHttpRequest' },
230 | },
231 | reason: FetchrError.BAD_HTTP_STATUS,
232 | statusCode: 400,
233 | timeout: 3000,
234 | url: 'http://localhost:3000/api/error',
235 | });
236 | });
237 |
238 | it('can handle service unexpected errors', async () => {
239 | const response = await page.evaluate(() => {
240 | const fetcher = new Fetchr();
241 | return fetcher
242 | .read('error', { error: 'unexpected' })
243 | .catch((err) => err);
244 | });
245 |
246 | expect(response).to.deep.equal({
247 | body: {
248 | output: { message: 'unexpected' },
249 | meta: {},
250 | },
251 | message: '{"output":{"message":"unexpected"},"meta":{}}',
252 | meta: {},
253 | name: 'FetchrError',
254 | output: { message: 'unexpected' },
255 | rawRequest: {
256 | url: 'http://localhost:3000/api/error;error=unexpected',
257 | method: 'GET',
258 | headers: { 'X-Requested-With': 'XMLHttpRequest' },
259 | },
260 | reason: FetchrError.BAD_HTTP_STATUS,
261 | statusCode: 500,
262 | timeout: 3000,
263 | url: 'http://localhost:3000/api/error;error=unexpected',
264 | });
265 | });
266 |
267 | it('can handle incorrect api path', async () => {
268 | const response = await page.evaluate(() => {
269 | const fetcher = new Fetchr({ xhrPath: '/non-existent' });
270 | return fetcher.read('item').catch((err) => err);
271 | });
272 |
273 | expect(response).to.deep.equal({
274 | body: { error: 'page not found' },
275 | message: '{"error":"page not found"}',
276 | meta: null,
277 | name: 'FetchrError',
278 | output: null,
279 | rawRequest: {
280 | url: 'http://localhost:3000/non-existent/item',
281 | method: 'GET',
282 | headers: { 'X-Requested-With': 'XMLHttpRequest' },
283 | },
284 | reason: FetchrError.BAD_HTTP_STATUS,
285 | statusCode: 404,
286 | timeout: 3000,
287 | url: 'http://localhost:3000/non-existent/item',
288 | });
289 | });
290 |
291 | it('can handle aborts', async () => {
292 | const response = await page.evaluate(() => {
293 | const fetcher = new Fetchr();
294 | const request = fetcher.read('slow');
295 |
296 | // We need to abort after 300ms since the current
297 | // implementation does not trigger a request when
298 | // calling read with only one argument. We could
299 | // call .end() or .then() without any callback,
300 | // but doing it with a setTimeout is more
301 | // realistic since it's very likely that users
302 | // would have abort inside an event handler in a
303 | // different stack.
304 | const abort = () =>
305 | new Promise((resolve) => {
306 | setTimeout(() => {
307 | request.abort();
308 | resolve();
309 | }, 300);
310 | });
311 |
312 | return Promise.all([request, abort()]).catch((err) => err);
313 | });
314 |
315 | expect(response).to.deep.equal({
316 | body: null,
317 | message: 'signal is aborted without reason',
318 | meta: null,
319 | name: 'FetchrError',
320 | output: null,
321 | rawRequest: {
322 | url: 'http://localhost:3000/api/slow',
323 | method: 'GET',
324 | headers: { 'X-Requested-With': 'XMLHttpRequest' },
325 | },
326 | reason: FetchrError.ABORT,
327 | statusCode: 0,
328 | timeout: 3000,
329 | url: 'http://localhost:3000/api/slow',
330 | });
331 | });
332 |
333 | it('can abort after a timeout', async () => {
334 | // This test triggers a call to the slow resource
335 | // (which always takes 5s to respond) with a timeout
336 | // of 200ms. After that, we schedule an abort call
337 | // after 500ms (in the middle of the 3rd call).
338 |
339 | // Since the abort and the timeout mechanisms share
340 | // the same AbortController instance, this test
341 | // assures that after internal abortions (due to the
342 | // timeouts) it's still possible to make the user
343 | // abort mechanism work.
344 |
345 | const response = await page.evaluate(() => {
346 | const fetcher = new Fetchr();
347 | const promise = fetcher.read('slow', null, {
348 | retry: { maxRetries: 5, interval: 0 },
349 | timeout: 200,
350 | });
351 |
352 | return new Promise((resolve) =>
353 | setTimeout(() => {
354 | promise.abort();
355 | resolve();
356 | }, 500),
357 | ).then(() => promise.catch((err) => err));
358 | });
359 |
360 | expect(response.reason).to.equal(FetchrError.ABORT);
361 | });
362 |
363 | it('can handle timeouts', async () => {
364 | const response = await page.evaluate(() => {
365 | const fetcher = new Fetchr();
366 | return fetcher
367 | .read('error', { error: 'timeout' }, { timeout: 20 })
368 | .catch((err) => err);
369 | });
370 |
371 | expect(response).to.deep.equal({
372 | body: null,
373 | message: 'Request failed due to timeout',
374 | meta: null,
375 | name: 'FetchrError',
376 | output: null,
377 | rawRequest: {
378 | url: 'http://localhost:3000/api/error;error=timeout',
379 | method: 'GET',
380 | headers: { 'X-Requested-With': 'XMLHttpRequest' },
381 | },
382 | reason: FetchrError.TIMEOUT,
383 | statusCode: 0,
384 | timeout: 20,
385 | url: 'http://localhost:3000/api/error;error=timeout',
386 | });
387 | });
388 |
389 | it('can retry failed requests', async () => {
390 | const response = await page.evaluate(() => {
391 | const fetcher = new Fetchr();
392 | return fetcher
393 | .read(
394 | 'error',
395 | { error: 'retry' },
396 | { retry: { maxRetries: 2 } },
397 | )
398 | .catch((err) => err);
399 | });
400 |
401 | expect(response).to.deep.equal({
402 | data: { retry: 'ok' },
403 | meta: {},
404 | });
405 | });
406 |
407 | it('can retry timed out requests', async () => {
408 | // This test makes sure that we are renewing the
409 | // AbortController for each new request
410 | // attempt. Otherwise, after the first AbortController
411 | // is triggered, all the following requests would fail
412 | // instantly.
413 |
414 | const response = await page.evaluate(() => {
415 | const fetcher = new Fetchr();
416 | return fetcher
417 | .read('slow-then-fast', { reset: true })
418 | .then(() =>
419 | fetcher.read('slow-then-fast', null, {
420 | retry: { maxRetries: 5 },
421 | timeout: 80,
422 | }),
423 | );
424 | });
425 |
426 | expect(response.data.attempts).to.equal(3);
427 | });
428 | });
429 |
430 | describe('POST', () => {
431 | it('can handle unconfigured server', async () => {
432 | const response = await page.evaluate(() => {
433 | const fetcher = new Fetchr({
434 | xhrPath: 'http://localhost:3001',
435 | });
436 | return fetcher.create('error').catch((err) => err);
437 | });
438 |
439 | expect(response).to.deep.equal({
440 | body: null,
441 | message: 'Failed to fetch',
442 | meta: null,
443 | name: 'FetchrError',
444 | output: null,
445 | rawRequest: {
446 | url: 'http://localhost:3001/error',
447 | method: 'POST',
448 | headers: {
449 | 'Content-Type': 'application/json',
450 | 'X-Requested-With': 'XMLHttpRequest',
451 | },
452 | },
453 | reason: FetchrError.UNKNOWN,
454 | statusCode: 0,
455 | timeout: 3000,
456 | url: 'http://localhost:3001/error',
457 | });
458 | });
459 |
460 | it('can handle service expected errors', async () => {
461 | const response = await page.evaluate(() => {
462 | const fetcher = new Fetchr();
463 | return fetcher.create('error').catch((err) => err);
464 | });
465 |
466 | expect(response).to.deep.equal({
467 | body: {
468 | output: { message: 'error' },
469 | meta: { foo: 'bar' },
470 | },
471 | message:
472 | '{"output":{"message":"error"},"meta":{"foo":"bar"}}',
473 | meta: { foo: 'bar' },
474 | name: 'FetchrError',
475 | output: { message: 'error' },
476 | rawRequest: {
477 | url: 'http://localhost:3000/api/error',
478 | method: 'POST',
479 | headers: {
480 | 'Content-Type': 'application/json',
481 | 'X-Requested-With': 'XMLHttpRequest',
482 | },
483 | },
484 | reason: FetchrError.BAD_HTTP_STATUS,
485 | statusCode: 400,
486 | timeout: 3000,
487 | url: 'http://localhost:3000/api/error',
488 | });
489 | });
490 |
491 | it('can handle service unexpected errors', async () => {
492 | const response = await page.evaluate(() => {
493 | const fetcher = new Fetchr();
494 | return fetcher
495 | .create('error', { error: 'unexpected' })
496 | .catch((err) => err);
497 | });
498 |
499 | expect(response).to.deep.equal({
500 | body: {
501 | output: { message: 'unexpected' },
502 | meta: {},
503 | },
504 | message: '{"output":{"message":"unexpected"},"meta":{}}',
505 | meta: {},
506 | name: 'FetchrError',
507 | output: { message: 'unexpected' },
508 | rawRequest: {
509 | url: 'http://localhost:3000/api/error',
510 | method: 'POST',
511 | headers: {
512 | 'Content-Type': 'application/json',
513 | 'X-Requested-With': 'XMLHttpRequest',
514 | },
515 | },
516 | reason: FetchrError.BAD_HTTP_STATUS,
517 | statusCode: 500,
518 | timeout: 3000,
519 | url: 'http://localhost:3000/api/error',
520 | });
521 | });
522 |
523 | it('can handle incorrect api path', async () => {
524 | const response = await page.evaluate(() => {
525 | const fetcher = new Fetchr({ xhrPath: '/non-existent' });
526 | return fetcher.create('item').catch((err) => err);
527 | });
528 |
529 | expect(response).to.deep.equal({
530 | body: { error: 'page not found' },
531 | message: '{"error":"page not found"}',
532 | meta: null,
533 | name: 'FetchrError',
534 | output: null,
535 | rawRequest: {
536 | url: 'http://localhost:3000/non-existent/item',
537 | method: 'POST',
538 | headers: {
539 | 'Content-Type': 'application/json',
540 | 'X-Requested-With': 'XMLHttpRequest',
541 | },
542 | },
543 | reason: FetchrError.BAD_HTTP_STATUS,
544 | statusCode: 404,
545 | timeout: 3000,
546 | url: 'http://localhost:3000/non-existent/item',
547 | });
548 | });
549 |
550 | it('can handle timeouts', async () => {
551 | const response = await page.evaluate(() => {
552 | const fetcher = new Fetchr();
553 | return fetcher
554 | .create('error', { error: 'timeout' }, null, {
555 | timeout: 20,
556 | })
557 | .catch((err) => err);
558 | });
559 |
560 | expect(response).to.deep.equal({
561 | body: null,
562 | message: 'Request failed due to timeout',
563 | meta: null,
564 | name: 'FetchrError',
565 | output: null,
566 | rawRequest: {
567 | url: 'http://localhost:3000/api/error',
568 | method: 'POST',
569 | headers: {
570 | 'Content-Type': 'application/json',
571 | 'X-Requested-With': 'XMLHttpRequest',
572 | },
573 | },
574 | reason: FetchrError.TIMEOUT,
575 | statusCode: 0,
576 | timeout: 20,
577 | url: 'http://localhost:3000/api/error',
578 | });
579 | });
580 |
581 | it('can retry failed requests', async () => {
582 | const response = await page.evaluate(() => {
583 | const fetcher = new Fetchr();
584 | return fetcher
585 | .create('error', { error: 'retry' }, null, {
586 | retry: { maxRetries: 2 },
587 | unsafeAllowRetry: true,
588 | })
589 | .catch((err) => err);
590 | });
591 |
592 | expect(response).to.deep.equal({
593 | data: { retry: 'ok' },
594 | meta: {},
595 | });
596 | });
597 | });
598 |
599 | describe('client errors', () => {
600 | it('handles unknown resources', async () => {
601 | const response = await page.evaluate(() => {
602 | const fetcher = new Fetchr();
603 | return fetcher
604 | .create('unknown-resource')
605 | .catch((err) => err);
606 | });
607 |
608 | expect(response.statusCode).to.equal(400);
609 | expect(response.message).to.equal(
610 | 'Resource "unknown*resource" is not registered',
611 | );
612 | });
613 |
614 | it('handles not implemented operations', async () => {
615 | const response = await page.evaluate(() => {
616 | const fetcher = new Fetchr();
617 | return fetcher.delete('error').catch((err) => err);
618 | });
619 |
620 | expect(response.statusCode).to.equal(405);
621 | expect(JSON.parse(response.message).output.message).to.equal(
622 | 'Operation "delete" is not allowed for resource "error"',
623 | );
624 | });
625 | });
626 | });
627 |
628 | describe('headers', () => {
629 | it('can handle request and response headers', async () => {
630 | const response = await page.evaluate(() => {
631 | const fetcher = new Fetchr();
632 | return fetcher
633 | .read('header', null, {
634 | headers: { 'x-fetchr-request': '42' },
635 | })
636 | .catch((err) => err);
637 | });
638 |
639 | expect(response).to.deep.equal({
640 | data: { headers: 'ok' },
641 | meta: {
642 | headers: { 'x-fetchr-response': '42' },
643 | },
644 | });
645 | });
646 | });
647 | });
648 |
--------------------------------------------------------------------------------
/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 | const qs = require('querystring');
6 | const fumble = require('fumble');
7 |
8 | const OP_READ = 'read';
9 | const OP_CREATE = 'create';
10 | const OP_UPDATE = 'update';
11 | const OP_DELETE = 'delete';
12 | const OPERATIONS = [OP_READ, OP_CREATE, OP_UPDATE, OP_DELETE];
13 |
14 | const RESOURCE_SANTIZER_REGEXP = /[^\w.]+/g;
15 |
16 | class FetchrError extends Error {
17 | constructor(message) {
18 | super(message);
19 | this.name = 'FetchrError';
20 | }
21 | }
22 |
23 | function _checkResourceHandlers(service) {
24 | for (const operation of OPERATIONS) {
25 | const handler = service[operation];
26 | if (!handler) {
27 | continue;
28 | }
29 |
30 | if (handler.length > 1) {
31 | console.warn(
32 | `${service.resource} ${operation} handler is callback based. Callback based resource handlers are deprecated and will be removed in the next version.`,
33 | );
34 | }
35 | }
36 | }
37 |
38 | function parseValue(value) {
39 | // take care of value of type: array, object
40 | try {
41 | let ret = JSON.parse(value);
42 | // Big interger, big decimal and the number in exponential notations will results
43 | // in unexpected form. e.g. 1234e1234 will be parsed into Infinity and the
44 | // number > MAX_SAFE_INTEGER will cause a rounding error.
45 | // So we will just leave them as strings instead.
46 | if (typeof ret === 'number' && String(value) !== String(ret)) {
47 | ret = value;
48 | }
49 | return ret;
50 | } catch (e) {
51 | return value;
52 | }
53 | }
54 |
55 | function parseParamValues(params) {
56 | return Object.keys(params).reduce((parsed, curr) => {
57 | parsed[curr] = parseValue(params[curr]);
58 | return parsed;
59 | }, {});
60 | }
61 |
62 | function parseRequest(req) {
63 | if (req.method === 'GET') {
64 | const path = req.path.substr(1).split(';');
65 | const resource = path.shift();
66 | const operation = 'read';
67 | const params = parseParamValues(qs.parse(path.join('&')));
68 | return { resource, operation, params };
69 | }
70 |
71 | const { resource, operation, body = {}, params } = req.body || {};
72 | return { resource, operation, body, params };
73 | }
74 |
75 | function sanitizeResourceName(resource) {
76 | return resource
77 | ? resource.replace(RESOURCE_SANTIZER_REGEXP, '*')
78 | : resource;
79 | }
80 |
81 | function emptyResourceError() {
82 | const error = fumble.http.badRequest('No resource specified', {
83 | debug: 'No resource',
84 | });
85 | error.source = 'fetchr';
86 | return error;
87 | }
88 |
89 | function badResourceError(resource) {
90 | const resourceName = sanitizeResourceName(resource);
91 | const errorMsg = `Resource "${resourceName}" is not registered`;
92 | const error = fumble.http.badRequest(errorMsg, {
93 | debug: `Bad resource ${resourceName}`,
94 | });
95 | error.source = 'fetchr';
96 | return error;
97 | }
98 |
99 | function badOperationError(resource, operation) {
100 | const resourceName = sanitizeResourceName(resource);
101 | const error = fumble.http.badRequest(
102 | `Unsupported "${resourceName}.${operation}" operation`,
103 | {
104 | debug: 'Only "create", "read", "update" or "delete" operations are allowed',
105 | },
106 | );
107 | error.source = 'fetchr';
108 | return error;
109 | }
110 |
111 | /**
112 | * Takes an error and resolves output and statusCode to respond to client with
113 | *
114 | * @param {Error} JavaScript error
115 | * @return {Object} object with resolved statusCode & output
116 | */
117 | function getErrorResponse(err) {
118 | const statusCode = err.statusCode || 500;
119 | let output = {
120 | message: 'request failed',
121 | };
122 |
123 | if (typeof err.output !== 'undefined') {
124 | output = err.output;
125 | } else if (err.message) {
126 | output.message = err.message;
127 | }
128 |
129 | return {
130 | statusCode,
131 | output,
132 | };
133 | }
134 |
135 | /**
136 | * A Request instance represents a single fetcher request.
137 | * The constructor requires `operation` (CRUD) and `resource`.
138 | */
139 | class Request {
140 | /**
141 | * @param {String} operation The CRUD operation name: 'create|read|update|delete'.
142 | * @param {String} resource name of service
143 | * @param {Object} options configuration options for Request
144 | * @param {Object} [options.req] The request object from
145 | * express/connect. It can contain per-request/context data.
146 | * @param {Array} [options.serviceMeta] Array to hold
147 | * per-request/session metadata from all service calls.
148 | * @param {Function} [options.statsCollector] The function will be
149 | * invoked with 1 argument: the stats object, which contains
150 | * resource, operation, params (request params), statusCode, err,
151 | * and time (elapsed time)
152 | * @param {Function} [options.paramsProcessor] The function will
153 | * be invoked with 3 arguments: the req object, the serviceInfo
154 | * object, and the params object. It is expected to return the
155 | * processed params object.
156 | */
157 | constructor(operation, resource, options = {}) {
158 | if (!resource) {
159 | throw new FetchrError('Resource is required for a fetcher request');
160 | }
161 |
162 | this.operation = operation || OP_READ;
163 | this.resource = resource;
164 | this.req = options.req || {};
165 | this.serviceMeta = options.serviceMeta || [];
166 | this._params = {};
167 | this._body = null;
168 | this._clientConfig = {};
169 | this._startTime = 0;
170 | this._statsCollector = options.statsCollector;
171 | this._paramsProcessor = options.paramsProcessor;
172 | }
173 |
174 | /**
175 | * Add params to this fetcher request
176 | * @param {Object} params Information carried in query and matrix
177 | * parameters in typical REST API.
178 | * @returns {this}
179 | */
180 | params(params) {
181 | this._params =
182 | typeof this._paramsProcessor === 'function'
183 | ? this._paramsProcessor(
184 | this.req,
185 | { operation: this.operation, resource: this.resource },
186 | params,
187 | )
188 | : params;
189 | return this;
190 | }
191 |
192 | /**
193 | * Add body to this fetcher request
194 | * @param {Object} body The JSON object that contains the resource
195 | * data being updated for this request. Not used for read and
196 | * delete operations.
197 | * @returns {this}
198 | */
199 | body(body) {
200 | this._body = body;
201 | return this;
202 | }
203 |
204 | /**
205 | * Add clientConfig to this fetcher request.
206 | * @param {Object} config config for this fetcher request
207 | * @returns {this}
208 | */
209 | clientConfig(config) {
210 | this._clientConfig = config;
211 | return this;
212 | }
213 |
214 | /**
215 | * Capture meta data; capture stats for this request and pass
216 | * stats data to options.statsCollector.
217 | * @param {Object} errData The error response for failed request
218 | * @param {Object} result The response data for successful request
219 | */
220 | _captureMetaAndStats(err, meta) {
221 | if (meta) {
222 | this.serviceMeta.push(meta);
223 | }
224 | if (typeof this._statsCollector === 'function') {
225 | this._statsCollector({
226 | resource: this.resource,
227 | operation: this.operation,
228 | params: this._params,
229 | statusCode: err
230 | ? err.statusCode
231 | : (meta && meta.statusCode) || 200,
232 | err,
233 | time: Date.now() - this._startTime,
234 | });
235 | }
236 | }
237 |
238 | /**
239 | * Execute this fetcher request
240 | * @returns {Promise}
241 | */
242 | async _executeRequest() {
243 | if (!Fetcher.isRegistered(this.resource)) {
244 | const err = new FetchrError(
245 | `Service "${sanitizeResourceName(this.resource)}" could not be found`,
246 | );
247 | return { err };
248 | }
249 |
250 | const service = Fetcher.getService(this.resource);
251 | const handler = service[this.operation];
252 |
253 | if (!handler) {
254 | const err = new FetchrError(
255 | `Operation "${this.operation}" is not allowed for resource "${sanitizeResourceName(this.resource)}"`,
256 | );
257 | err.statusCode = 405;
258 |
259 | return { err };
260 | }
261 |
262 | // async based handler
263 | if (handler.length <= 1) {
264 | return handler
265 | .call(service, {
266 | body: this._body,
267 | config: this._clientConfig,
268 | params: this._params,
269 | req: this.req,
270 | resource: this.resource,
271 | })
272 | .catch((err) => ({ err }));
273 | }
274 |
275 | // callback based handler
276 | return new Promise((resolve) => {
277 | const args = [
278 | this.req,
279 | this.resource,
280 | this._params,
281 | this._clientConfig,
282 | function executeRequestCallback(err, data, meta) {
283 | resolve({ err, data, meta });
284 | },
285 | ];
286 |
287 | if (this.operation === OP_CREATE || this.operation === OP_UPDATE) {
288 | args.splice(3, 0, this._body);
289 | }
290 |
291 | try {
292 | handler.apply(service, args);
293 | } catch (err) {
294 | resolve({ err });
295 | }
296 | });
297 | }
298 |
299 | /**
300 | * Execute this fetcher request and call callback.
301 | * @param {fetcherCallback} callback callback invoked when service
302 | * is complete.
303 | */
304 | end(callback) {
305 | if (!arguments.length) {
306 | console.warn(
307 | 'You called .end() without a callback. This will become an error in the future. Use .then() instead.',
308 | );
309 | }
310 |
311 | this._startTime = Date.now();
312 |
313 | return this._executeRequest().then(({ err, data, meta }) => {
314 | this._captureMetaAndStats(err, meta);
315 | if (callback) {
316 | callback(err, data, meta);
317 | return;
318 | }
319 |
320 | if (err) {
321 | throw err;
322 | }
323 |
324 | return { data, meta };
325 | });
326 | }
327 |
328 | then(resolve, reject) {
329 | return this.end((err, data, meta) => {
330 | if (err) {
331 | reject(err);
332 | } else {
333 | resolve({ data, meta });
334 | }
335 | });
336 | }
337 |
338 | catch(reject) {
339 | return this.end((err) => {
340 | if (err) {
341 | reject(err);
342 | }
343 | });
344 | }
345 | }
346 |
347 | class Fetcher {
348 | /**
349 | * Fetcher class for the server.
350 | * Provides interface to register data services and
351 | * to later access those services.
352 | * @class Fetcher
353 | * @param {Object} options configuration options for Fetcher.
354 | * @param {Object} [options.req] The express request object. It
355 | * can contain per-request/context data.
356 | * @param {string} [options.xhrPath="/api"] The path for XHR
357 | * requests. Will be ignored server side.
358 | * @param {Function} [options.statsCollector] The function will be
359 | * invoked with 1 argument: the stats object, which contains
360 | * resource, operation, params (request params), statusCode, err,
361 | * and time (elapsed time).
362 | * @param {Function} [options.paramsProcessor] The function will
363 | * be invoked with 3 arguments: the req object, the serviceInfo
364 | * object, and the params object. It is expected to return the
365 | * processed params object.
366 | * @constructor
367 | */
368 | constructor(options = {}) {
369 | this.options = options;
370 | this.req = this.options.req || {};
371 | this._serviceMeta = [];
372 | }
373 |
374 | static services = {};
375 |
376 | static _deprecatedServicesDefinitions = [];
377 |
378 | /**
379 | * Register a data fetcher
380 | * @deprecated Use registerService.
381 | * @param {Function} fetcher
382 | */
383 | static registerFetcher(fetcher) {
384 | // TODO: Uncomment warnings in next minor release
385 | // if ('production' !== process.env.NODE_ENV) {
386 | // console.warn('Fetcher.registerFetcher is deprecated. ' +
387 | // 'Please use Fetcher.registerService instead.');
388 | // }
389 | return Fetcher.registerService(fetcher);
390 | }
391 |
392 | /**
393 | * Register a data service
394 | * @param {Function} service
395 | */
396 | static registerService(service) {
397 | if (!service) {
398 | throw new FetchrError(
399 | 'Fetcher.registerService requires a service definition (ex. registerService(service)).',
400 | );
401 | }
402 |
403 | let resource;
404 | if (typeof service.resource !== 'undefined') {
405 | resource = service.resource;
406 | } else if (typeof service.name !== 'undefined') {
407 | resource = service.name;
408 | Fetcher._deprecatedServicesDefinitions.push(resource);
409 | } else {
410 | throw new FetchrError(
411 | '"resource" property is missing in service definition.',
412 | );
413 | }
414 | _checkResourceHandlers(service);
415 |
416 | Fetcher.services[resource] = service;
417 | return;
418 | }
419 |
420 | /**
421 | * Retrieve a data fetcher by name
422 | * @deprecated Use getService
423 | * @param {String} name oresource @returns {Function} fetcher.
424 | */
425 | static getFetcher(name) {
426 | // TODO: Uncomment warnings in next minor release
427 | // if ('production' !== process.env.NODE_ENV) {
428 | // console.warn('Fetcher.getFetcher is deprecated. ' +
429 | // 'Please use Fetcher.getService instead.');
430 | // }
431 | return Fetcher.getService(name);
432 | }
433 |
434 | /**
435 | * Retrieve a data service by name
436 | * @param {String} name of service
437 | * @returns {Function} service
438 | */
439 | static getService(name) {
440 | //Access service by name
441 | const service = Fetcher.isRegistered(name);
442 | if (!service) {
443 | throw new FetchrError(
444 | `Service "${sanitizeResourceName(name)}" could not be found`,
445 | );
446 | }
447 | return service;
448 | }
449 |
450 | /**
451 | * Returns true if service with name has been registered
452 | * @param {String} name of service
453 | * @returns {Boolean} true if service with name was registered
454 | */
455 | static isRegistered(name) {
456 | return name && Fetcher.services[name.split('.')[0]];
457 | }
458 |
459 | /**
460 | * Returns express/connect middleware for Fetcher
461 | * @param {Object} [options] Optional configurations
462 | * @param {Function} [options.responseFormatter=no op function]
463 | * Function to modify the response before sending to client. First
464 | * argument is the HTTP request object, second argument is the
465 | * HTTP response object and the third argument is the service data
466 | * object.
467 | * @param {Function} [options.statsCollector] The function will be
468 | * invoked with 1 argument: the stats object, which contains
469 | * resource, operation, params (request params), statusCode, err,
470 | * and time (elapsed time).
471 | * @param {Function} [options.paramsProcessor] The function will
472 | * be invoked with 3 arguments: the req object, the serviceInfo
473 | * object, and the params object. It is expected to return the
474 | * processed params object.
475 | * @returns {Function} middleware
476 | */
477 | static middleware(options = {}) {
478 | if (
479 | Fetcher._deprecatedServicesDefinitions.length &&
480 | 'production' !== process.env.NODE_ENV
481 | ) {
482 | const services = Fetcher._deprecatedServicesDefinitions
483 | .sort()
484 | .join(', ');
485 |
486 | console.warn(`You have registered services using a deprecated property.
487 | Please, rename the property "name" to "resource" in the
488 | following services definitions: ${services}.`);
489 | }
490 |
491 | const { paramsProcessor, statsCollector, responseFormatter } = options;
492 | const formatResponse = responseFormatter || ((req, res, data) => data);
493 |
494 | return (req, res, next) => {
495 | const { body, operation, params, resource } = parseRequest(req);
496 |
497 | if (!resource) {
498 | return next(emptyResourceError());
499 | }
500 |
501 | if (!Fetcher.isRegistered(resource)) {
502 | return next(badResourceError(resource));
503 | }
504 |
505 | if (
506 | operation !== OP_CREATE &&
507 | operation !== OP_UPDATE &&
508 | operation !== OP_DELETE &&
509 | operation !== OP_READ
510 | ) {
511 | return next(badOperationError(resource, operation));
512 | }
513 |
514 | new Request(operation, resource, {
515 | req,
516 | statsCollector,
517 | paramsProcessor,
518 | })
519 | .params(params)
520 | .body(body)
521 | .end((err, data, meta = {}) => {
522 | if (meta.headers) {
523 | res.set(meta.headers);
524 | }
525 | if (err) {
526 | const { statusCode, output } = getErrorResponse(err);
527 | res.status(statusCode).json(
528 | formatResponse(req, res, { output, meta }),
529 | );
530 | } else {
531 | res.status(meta.statusCode || 200).json(
532 | formatResponse(req, res, { data, meta }),
533 | );
534 | }
535 | });
536 | };
537 | }
538 |
539 | /**
540 | * read operation (read as in CRUD).
541 | * @param {String} resource The resource name.
542 | * @param {Object} params The parameters identify the resource,
543 | * and along with information carried in query and matrix
544 | * parameters in typical REST API.
545 | * @param {Object} [config={}] The config object. It can contain
546 | * "config" for per-request config data.
547 | * @param {fetcherCallback} callback callback invoked when fetcher
548 | * is complete.
549 | */
550 | read(resource, params, config, callback) {
551 | const request = new Request('read', resource, {
552 | req: this.req,
553 | serviceMeta: this._serviceMeta,
554 | statsCollector: this.options.statsCollector,
555 | });
556 | if (1 === arguments.length) {
557 | return request;
558 | }
559 | // TODO: Uncomment warnings in next minor release
560 | // if ('production' !== process.env.NODE_ENV) {
561 | // console.warn('The recommended way to use fetcher\'s .read method is \n' +
562 | // '.read(\'' + resource + '\').params({foo:bar}).end(callback);');
563 | // }
564 | // TODO: Remove below this line in release after next
565 | if (typeof config === 'function') {
566 | callback = config;
567 | config = {};
568 | }
569 | return request.params(params).clientConfig(config).end(callback);
570 | }
571 |
572 | /**
573 | * Create operation (create as in CRUD).
574 | * @param {String} resource The resource name.
575 | * @param {Object} params The parameters identify the resource,
576 | * and along with information carried in query and matrix
577 | * parameters in typical REST API.
578 | * @param {Object} body The JSON object that contains the resource
579 | * data that is being created.
580 | * @param {Object} [config={}] The config object. It can contain
581 | * "config" for per-request config data.
582 | * @param {fetcherCallback} callback callback invoked when fetcher
583 | * is complete.
584 | */
585 | create(resource, params, body, config, callback) {
586 | const request = new Request('create', resource, {
587 | req: this.req,
588 | serviceMeta: this._serviceMeta,
589 | statsCollector: this.options.statsCollector,
590 | });
591 | if (1 === arguments.length) {
592 | return request;
593 | }
594 | // TODO: Uncomment warnings in next minor release
595 | // if ('production' !== process.env.NODE_ENV) {
596 | // console.warn('The recommended way to use fetcher\'s .create method is \n' +
597 | // '.create(\'' + resource + '\').params({foo:bar}).body({}).end(callback);');
598 | // }
599 | // TODO: Remove below this line in release after next
600 | if (typeof config === 'function') {
601 | callback = config;
602 | config = {};
603 | }
604 | return request
605 | .params(params)
606 | .body(body)
607 | .clientConfig(config)
608 | .end(callback);
609 | }
610 |
611 | /**
612 | * Update operation (update as in CRUD).
613 | * @param {String} resource The resource name
614 | * @param {Object} params The parameters identify the resource,
615 | * and along with information carried in query and matrix
616 | * parameters in typical REST API.
617 | * @param {Object} body The JSON object that contains the resource
618 | * data that is being updated.
619 | * @param {Object} [config={}] The config object. It can contain
620 | * "config" for per-request config data.
621 | * @param {fetcherCallback} callback callback invoked when
622 | * fetcher is complete.
623 | */
624 | update(resource, params, body, config, callback) {
625 | const request = new Request('update', resource, {
626 | req: this.req,
627 | serviceMeta: this._serviceMeta,
628 | statsCollector: this.options.statsCollector,
629 | });
630 | if (1 === arguments.length) {
631 | return request;
632 | }
633 | // TODO: Uncomment warnings in next minor release
634 | // if ('production' !== process.env.NODE_ENV) {
635 | // console.warn('The recommended way to use fetcher\'s .update method is \n' +
636 | // '.update(\'' + resource + '\').params({foo:bar}).body({}).end(callback);');
637 | // }
638 | // TODO: Remove below this line in release after next
639 | if (typeof config === 'function') {
640 | callback = config;
641 | config = {};
642 | }
643 | return request
644 | .params(params)
645 | .body(body)
646 | .clientConfig(config)
647 | .end(callback);
648 | }
649 |
650 | /**
651 | * Delete operation (delete as in CRUD).
652 | * @param {String} resource The resource name
653 | * @param {Object} params The parameters identify the resource,
654 | * and along with information carried in query and matrix
655 | * parameters in typical REST API.
656 | * @param {Object} [config={}] The config object. It can contain
657 | * "config" for per-request config data.
658 | * @param {fetcherCallback} callback callback invoked when
659 | * fetcher is complete.
660 | */
661 | delete(resource, params, config, callback) {
662 | const request = new Request('delete', resource, {
663 | req: this.req,
664 | serviceMeta: this._serviceMeta,
665 | statsCollector: this.options.statsCollector,
666 | });
667 | if (1 === arguments.length) {
668 | return request;
669 | }
670 |
671 | // TODO: Uncomment warnings in next minor release
672 | // if ('production' !== process.env.NODE_ENV) {
673 | // console.warn('The recommended way to use fetcher\'s .read method is \n' +
674 | // '.read(\'' + resource + '\').params({foo:bar}).end(callback);');
675 | // }
676 | // TODO: Remove below this line in release after next
677 | if (typeof config === 'function') {
678 | callback = config;
679 | config = {};
680 | }
681 | return request.params(params).clientConfig(config).end(callback);
682 | }
683 |
684 | /**
685 | * Update fetchr options
686 | * @param {Object} options configuration options for Fetcher.
687 | * @param {string} [options.xhrPath="/api"] The path for XHR
688 | * requests. Will be ignored server side.
689 | * @param {Object} [options.req] The request object. It can
690 | * contain per-request/context data.
691 | */
692 | updateOptions(options) {
693 | this.options = Object.assign(this.options, options);
694 | this.req = this.options.req || {};
695 | }
696 |
697 | /**
698 | * Get all the aggregated metadata sent data services in this
699 | * request.
700 | */
701 | getServiceMeta() {
702 | return this._serviceMeta;
703 | }
704 | }
705 |
706 | module.exports = Fetcher;
707 |
708 | /**
709 | * @callback fetcherCallback
710 | * @param {Object} err The request error, pass null if there was no
711 | * error. The data and meta parameters will be ignored if this
712 | * parameter is not null.
713 | * @param {number} [err.statusCode=500] http status code to return
714 | * @param {string} [err.message=request failed] http response body
715 | * @param {Object} data request result
716 | * @param {Object} [meta] request meta-data
717 | * @param {number} [meta.statusCode=200] http status code to return
718 | */
719 |
720 | /**
721 | * @typedef {Object} FetchrResponse
722 | * @property {?object} data - Any data returned by the fetchr resource.
723 | * @property {?object} meta - Any meta data returned by the fetchr resource.
724 | * @property {?Error} err - an error that occurred before, during or
725 | * after request was sent.
726 | */
727 |
--------------------------------------------------------------------------------
/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 |
6 | 'use strict';
7 |
8 | const fetchMock = require('fetch-mock');
9 | var expect = require('chai').expect;
10 | var mockery = require('mockery');
11 | var sinon = require('sinon');
12 | var supertest = require('supertest');
13 |
14 | var Fetcher = require('../../../libs/fetcher.client');
15 | var urlUtil = require('../../../libs/util/url');
16 | var httpRequest = require('../../../libs/util/httpRequest');
17 | var testCrud = require('../../util/testCrud');
18 | var defaultOptions = require('../../util/defaultOptions');
19 |
20 | // APP
21 | var defaultApp = require('../../mock/mockApp');
22 | var DEFAULT_PATH = defaultApp.DEFAULT_PATH;
23 | // CORS
24 | var corsApp = require('../../mock/mockCorsApp');
25 | var corsPath = corsApp.corsPath;
26 |
27 | var validateRequest = null;
28 |
29 | function handleFakeRequest(a, b, request) {
30 | var method = request.method.toLowerCase();
31 | var url = request.url;
32 | var app = defaultApp;
33 | if (url.indexOf(corsPath) !== -1) {
34 | // cors mode
35 | app = corsPath;
36 | url = url.substr(corsPath.length);
37 | if (url[0] !== '/') {
38 | url = '/' + url;
39 | }
40 | }
41 | return supertest(app)
42 | [method](url)
43 | .set(request.headers.entries())
44 | .send(request.body ? JSON.parse(request.body.toString()) : undefined)
45 | .then(function (res) {
46 | if (res.error) {
47 | // fetcher error
48 | return {
49 | status: res.error.status || 500,
50 | body: res.error.text,
51 | };
52 | }
53 | validateRequest && validateRequest(request);
54 | return {
55 | status: res.status,
56 | headers: res.headers,
57 | body: res.text,
58 | };
59 | })
60 | .catch((err) => {
61 | // superagent error
62 | return {
63 | status: 500,
64 | throws: err,
65 | };
66 | });
67 | }
68 |
69 | fetchMock.mock('*', handleFakeRequest);
70 |
71 | var context = { _csrf: 'stuff' };
72 | var resource = defaultOptions.resource;
73 | var params = defaultOptions.params;
74 | var body = defaultOptions.body;
75 | var config = defaultOptions.config;
76 | var callback = defaultOptions.callback;
77 | var resolve = defaultOptions.resolve;
78 | var reject = defaultOptions.reject;
79 |
80 | var stats = null;
81 |
82 | function statsCollector(s) {
83 | stats = s;
84 | }
85 |
86 | var callbackWithStats = function (operation, done) {
87 | return function (err, data, meta) {
88 | expect(stats.resource).to.eql(resource);
89 | expect(stats.operation).to.eql(operation);
90 | expect(stats.time).to.be.at.least(0);
91 | expect(stats.err).to.eql(err);
92 | expect(stats.statusCode).to.eql((err && err.statusCode) || 200);
93 | expect(stats.params).to.eql(params);
94 | callback(operation, done)(err, data, meta);
95 | };
96 | };
97 |
98 | describe('Client Fetcher', function () {
99 | after(() => {
100 | fetchMock.reset();
101 | });
102 |
103 | describe('DEFAULT', function () {
104 | before(function () {
105 | this.fetcher = new Fetcher({
106 | context: context,
107 | statsCollector: statsCollector,
108 | });
109 | validateRequest = function (req) {
110 | if (req.method === 'GET') {
111 | expect(req.url).to.contain(DEFAULT_PATH + '/' + resource);
112 | expect(req.url).to.contain('?_csrf=' + context._csrf);
113 | } else if (req.method === 'POST') {
114 | expect(req.url).to.equal(
115 | DEFAULT_PATH +
116 | '/' +
117 | resource +
118 | '?_csrf=' +
119 | context._csrf,
120 | );
121 | }
122 | };
123 | });
124 |
125 | beforeEach(function () {
126 | stats = null;
127 | });
128 |
129 | testCrud({
130 | params,
131 | body,
132 | config,
133 | callback: callbackWithStats,
134 | reject,
135 | resolve,
136 | });
137 |
138 | after(function () {
139 | validateRequest = null;
140 | });
141 | });
142 |
143 | describe('CORS', function () {
144 | before(function () {
145 | validateRequest = function (req) {
146 | if (req.method === 'GET') {
147 | expect(req.url).to.contain(corsPath);
148 | expect(req.url).to.contain('_csrf=' + context._csrf);
149 | } else if (req.method === 'POST') {
150 | expect(req.url).to.contain(
151 | '/' + resource + '?_csrf=' + context._csrf,
152 | );
153 | }
154 | };
155 | this.fetcher = new Fetcher({
156 | context: Object.assign({ cors: true }, context),
157 | corsPath: corsPath,
158 | });
159 | });
160 |
161 | testCrud({
162 | params,
163 | body,
164 | config: { cors: true },
165 | callback,
166 | reject,
167 | resolve,
168 | disableNoConfigTests: true,
169 | });
170 |
171 | after(function () {
172 | validateRequest = null;
173 | });
174 | });
175 |
176 | describe('request', function () {
177 | before(function () {
178 | this.fetcher = new Fetcher({
179 | context: context,
180 | });
181 | });
182 |
183 | it('should return request object when calling end w/ callback', function (done) {
184 | var operation = 'create';
185 | var request = this.fetcher[operation](resource)
186 | .params(params)
187 | .body(body)
188 | .clientConfig(config)
189 | .end(
190 | callback(operation, function (err) {
191 | if (err) {
192 | done(err);
193 | return;
194 | }
195 | expect(request.abort).to.exist;
196 | done();
197 | }),
198 | );
199 | });
200 | it('should be able to abort when calling end w/ callback', function () {
201 | var operation = 'create';
202 | var request = this.fetcher[operation](resource)
203 | .params(params)
204 | .body(body)
205 | .clientConfig(config)
206 | .end(
207 | callback(operation, function (err) {
208 | if (err) {
209 | // in this case, an error is good
210 | // we want the error to be thrown then request is aborted
211 | // done();
212 | }
213 | }),
214 | );
215 | expect(request.abort).to.exist;
216 | request.abort();
217 | });
218 | });
219 |
220 | describe('Timeout', function () {
221 | describe('should be configurable globally', function () {
222 | before(function () {
223 | mockery.registerMock('./util/httpRequest', function (options) {
224 | expect(options.timeout).to.equal(4000);
225 | return httpRequest(options);
226 | });
227 | mockery.enable({
228 | useCleanCache: true,
229 | warnOnUnregistered: false,
230 | });
231 | Fetcher = require('../../../libs/fetcher.client');
232 | this.fetcher = new Fetcher({
233 | context: context,
234 | xhrTimeout: 4000,
235 | });
236 | });
237 |
238 | testCrud({
239 | params,
240 | body,
241 | config,
242 | callback,
243 | resolve,
244 | reject,
245 | });
246 |
247 | after(function () {
248 | mockery.deregisterMock('./util/httpRequest');
249 | mockery.disable();
250 | });
251 | });
252 |
253 | describe('should be configurable per each fetchr call', function () {
254 | before(function () {
255 | mockery.registerMock('./util/httpRequest', function (options) {
256 | expect(options.timeout).to.equal(5000);
257 | return httpRequest(options);
258 | });
259 | mockery.enable({
260 | useCleanCache: true,
261 | warnOnUnregistered: false,
262 | });
263 | Fetcher = require('../../../libs/fetcher.client');
264 | this.fetcher = new Fetcher({
265 | context: context,
266 | xhrTimeout: 4000,
267 | });
268 | });
269 |
270 | testCrud({
271 | params,
272 | body,
273 | config: { timeout: 5000 },
274 | callback: callback,
275 | resolve: resolve,
276 | reject: reject,
277 | disableNoConfigTests: true,
278 | });
279 |
280 | after(function () {
281 | mockery.deregisterMock('./util/httpRequest');
282 | mockery.disable();
283 | });
284 | });
285 |
286 | describe('should default to DEFAULT_TIMEOUT of 3000', function () {
287 | before(function () {
288 | mockery.registerMock('./util/httpRequest', function (options) {
289 | expect(options.timeout).to.equal(3000);
290 | return httpRequest(options);
291 | });
292 | mockery.enable({
293 | useCleanCache: true,
294 | warnOnUnregistered: false,
295 | });
296 | Fetcher = require('../../../libs/fetcher.client');
297 | this.fetcher = new Fetcher({
298 | context: context,
299 | });
300 | });
301 |
302 | testCrud({
303 | params,
304 | body,
305 | config,
306 | callback,
307 | resolve,
308 | reject,
309 | });
310 |
311 | after(function () {
312 | mockery.deregisterMock('./util/httpRequest');
313 | mockery.disable();
314 | });
315 | });
316 | });
317 |
318 | describe('Context Picker', function () {
319 | var ctx = Object.assign({ random: 'randomnumber' }, context);
320 | before(function () {
321 | validateRequest = function (req) {
322 | if (req.method === 'GET') {
323 | expect(req.url).to.contain(DEFAULT_PATH + '/' + resource);
324 | expect(req.url).to.contain('?_csrf=' + ctx._csrf);
325 | expect(req.url).to.not.contain('random=' + ctx.random);
326 | } else if (req.method === 'POST') {
327 | expect(req.url).to.equal(
328 | DEFAULT_PATH +
329 | '/' +
330 | resource +
331 | '?_csrf=' +
332 | ctx._csrf +
333 | '&random=' +
334 | ctx.random,
335 | );
336 | }
337 | };
338 | });
339 | after(function () {
340 | validateRequest = null;
341 | });
342 |
343 | describe('Function', function () {
344 | before(function () {
345 | this.fetcher = new Fetcher({
346 | context: ctx,
347 | contextPicker: {
348 | GET: function getContextPicker(value, key) {
349 | if (key === 'random') {
350 | return false;
351 | }
352 | return true;
353 | },
354 | },
355 | });
356 | });
357 |
358 | testCrud({
359 | params,
360 | body,
361 | config,
362 | callback,
363 | resolve,
364 | reject,
365 | });
366 | });
367 |
368 | describe('Property Name', function () {
369 | before(function () {
370 | this.fetcher = new Fetcher({
371 | context: ctx,
372 | contextPicker: {
373 | GET: '_csrf',
374 | },
375 | });
376 | });
377 |
378 | testCrud({
379 | params,
380 | body,
381 | config,
382 | callback,
383 | resolve,
384 | reject,
385 | });
386 | });
387 |
388 | describe('Property Names', function () {
389 | before(function () {
390 | this.fetcher = new Fetcher({
391 | context: ctx,
392 | contextPicker: {
393 | GET: ['_csrf'],
394 | },
395 | });
396 | });
397 |
398 | testCrud({
399 | params,
400 | body,
401 | config,
402 | callback,
403 | resolve,
404 | reject,
405 | });
406 | });
407 | });
408 |
409 | describe('Custom constructGetUri', () => {
410 | it('is called correctly', () => {
411 | const fetcher = new Fetcher({});
412 | const constructGetUri = sinon.stub().callsFake(urlUtil.buildGETUrl);
413 |
414 | return fetcher
415 | .read('mock_service', { foo: 'bar' }, { constructGetUri })
416 | .then(() => {
417 | sinon.assert.calledOnceWithExactly(
418 | constructGetUri,
419 | '/api',
420 | 'mock_service',
421 | { foo: 'bar' },
422 | { constructGetUri },
423 | {},
424 | );
425 | });
426 | });
427 | });
428 |
429 | describe('Custom request headers', function () {
430 | var VERSION = '1.0.0';
431 |
432 | describe('should be configurable globally', function () {
433 | before(function () {
434 | mockery.registerMock('./util/httpRequest', function (options) {
435 | expect(options.headers['X-APP-VERSION']).to.equal(VERSION);
436 | return httpRequest(options);
437 | });
438 | mockery.enable({
439 | useCleanCache: true,
440 | warnOnUnregistered: false,
441 | });
442 | Fetcher = require('../../../libs/fetcher.client');
443 | this.fetcher = new Fetcher({
444 | context: context,
445 | headers: {
446 | 'X-APP-VERSION': VERSION,
447 | },
448 | });
449 | });
450 |
451 | testCrud({
452 | params,
453 | body,
454 | config,
455 | callback,
456 | resolve,
457 | reject,
458 | });
459 |
460 | after(function () {
461 | mockery.deregisterMock('./util/httpRequest');
462 | mockery.disable();
463 | });
464 | });
465 |
466 | describe('should be configurable per request', function () {
467 | before(function () {
468 | mockery.registerMock('./util/httpRequest', function (options) {
469 | expect(options.headers['X-APP-VERSION']).to.equal(VERSION);
470 | return httpRequest(options);
471 | });
472 | mockery.enable({
473 | useCleanCache: true,
474 | warnOnUnregistered: false,
475 | });
476 | Fetcher = require('../../../libs/fetcher.client');
477 | this.fetcher = new Fetcher({
478 | context: context,
479 | });
480 | });
481 |
482 | testCrud({
483 | params,
484 | body,
485 | config: { headers: { 'X-APP-VERSION': VERSION } },
486 | callback,
487 | resolve,
488 | reject,
489 | disableNoConfigTests: true,
490 | });
491 |
492 | after(function () {
493 | mockery.deregisterMock('./util/httpRequest');
494 | mockery.disable();
495 | });
496 | });
497 | });
498 |
499 | describe('updateOptions', function () {
500 | it('replaces all non mergeable options', function () {
501 | const f1 = () => {};
502 | const f2 = () => {};
503 |
504 | const fetcher = new Fetcher({
505 | corsPath: '/cors-path-1',
506 | statsCollector: f1,
507 | xhrPath: '/path-1',
508 | xhrTimeout: 1000,
509 | });
510 |
511 | fetcher.updateOptions({
512 | corsPath: '/cors-path-2',
513 | statsCollector: f2,
514 | xhrPath: '/path-2',
515 | xhrTimeout: 1500,
516 | });
517 |
518 | expect(fetcher.options.corsPath).to.equal('/cors-path-2');
519 | expect(fetcher.options.statsCollector).to.equal(f2);
520 | expect(fetcher.options.xhrPath).to.equal('/path-2');
521 | expect(fetcher.options.xhrTimeout).to.equal(1500);
522 | });
523 |
524 | it('merges context values', function () {
525 | const fetcher = new Fetcher({
526 | context: { a: 'a' },
527 | });
528 |
529 | fetcher.updateOptions({
530 | context: { b: 'b' },
531 | });
532 |
533 | expect(fetcher.options.context).to.deep.equal({
534 | a: 'a',
535 | b: 'b',
536 | });
537 | });
538 |
539 | describe('contextPicker', () => {
540 | const f1 = () => null;
541 | const f2 = () => null;
542 |
543 | it('keeps former contextPicker', () => {
544 | const fetcher = new Fetcher({
545 | contextPicker: { GET: 'a' },
546 | });
547 |
548 | fetcher.updateOptions({});
549 |
550 | expect(fetcher.options.contextPicker).to.deep.equal({
551 | GET: 'a',
552 | });
553 | });
554 |
555 | it('sets new contextPicker', () => {
556 | const fetcher = new Fetcher({});
557 |
558 | fetcher.updateOptions({
559 | contextPicker: { POST: 'b' },
560 | });
561 |
562 | expect(fetcher.options.contextPicker).to.deep.equal({
563 | POST: 'b',
564 | });
565 | });
566 |
567 | it('joins former and new contextPicker', () => {
568 | const fetcher = new Fetcher({
569 | contextPicker: { GET: 'a' },
570 | });
571 |
572 | fetcher.updateOptions({
573 | contextPicker: { POST: 'b' },
574 | });
575 |
576 | expect(fetcher.options.contextPicker).to.deep.equal({
577 | GET: 'a',
578 | POST: 'b',
579 | });
580 | });
581 |
582 | it('replaces string with string', () => {
583 | const fetcher = new Fetcher({
584 | contextPicker: { GET: 'a' },
585 | });
586 |
587 | fetcher.updateOptions({
588 | contextPicker: { GET: 'b' },
589 | });
590 |
591 | expect(fetcher.options.contextPicker).to.deep.equal({
592 | GET: 'b',
593 | });
594 | });
595 |
596 | it('replaces string with array', () => {
597 | const fetcher = new Fetcher({
598 | contextPicker: { GET: 'a' },
599 | });
600 |
601 | fetcher.updateOptions({
602 | contextPicker: { GET: ['b'] },
603 | });
604 |
605 | expect(fetcher.options.contextPicker).to.deep.equal({
606 | GET: ['b'],
607 | });
608 | });
609 |
610 | it('replaces string with function', () => {
611 | const fetcher = new Fetcher({
612 | contextPicker: { GET: 'a' },
613 | });
614 |
615 | fetcher.updateOptions({
616 | contextPicker: { GET: f2 },
617 | });
618 |
619 | expect(fetcher.options.contextPicker).to.deep.equal({
620 | GET: f2,
621 | });
622 | });
623 |
624 | it('replaces array with string', () => {
625 | const fetcher = new Fetcher({
626 | contextPicker: { GET: ['a'] },
627 | });
628 |
629 | fetcher.updateOptions({
630 | contextPicker: { GET: 'b' },
631 | });
632 |
633 | expect(fetcher.options.contextPicker).to.deep.equal({
634 | GET: 'b',
635 | });
636 | });
637 |
638 | it('merges array with array', () => {
639 | const fetcher = new Fetcher({
640 | contextPicker: { GET: ['a'] },
641 | });
642 |
643 | fetcher.updateOptions({
644 | contextPicker: { GET: ['b'] },
645 | });
646 |
647 | expect(fetcher.options.contextPicker).to.deep.equal({
648 | GET: ['a', 'b'],
649 | });
650 | });
651 |
652 | it('replaces array with function', () => {
653 | const fetcher = new Fetcher({
654 | contextPicker: { GET: ['a'] },
655 | });
656 |
657 | fetcher.updateOptions({
658 | contextPicker: { GET: f2 },
659 | });
660 |
661 | expect(fetcher.options.contextPicker).to.deep.equal({
662 | GET: f2,
663 | });
664 | });
665 |
666 | it('replaces function with string', () => {
667 | const fetcher = new Fetcher({
668 | contextPicker: { GET: f1 },
669 | });
670 |
671 | fetcher.updateOptions({
672 | contextPicker: { GET: 'b' },
673 | });
674 |
675 | expect(fetcher.options.contextPicker).to.deep.equal({
676 | GET: 'b',
677 | });
678 | });
679 |
680 | it('replaces function with array', () => {
681 | const fetcher = new Fetcher({
682 | contextPicker: { GET: f1 },
683 | });
684 |
685 | fetcher.updateOptions({
686 | contextPicker: { GET: ['b'] },
687 | });
688 |
689 | expect(fetcher.options.contextPicker).to.deep.equal({
690 | GET: ['b'],
691 | });
692 | });
693 |
694 | it('replaces function with function', () => {
695 | const fetcher = new Fetcher({
696 | contextPicker: { GET: f1 },
697 | });
698 |
699 | fetcher.updateOptions({
700 | contextPicker: { GET: f2 },
701 | });
702 |
703 | expect(fetcher.options.contextPicker).to.deep.equal({
704 | GET: f2,
705 | });
706 | });
707 | });
708 | });
709 |
710 | describe('Custom retry', function () {
711 | describe('should be configurable globally', function () {
712 | before(function () {
713 | mockery.registerMock('./util/httpRequest', function (options) {
714 | expect(options.retry).to.deep.equal({
715 | interval: 350,
716 | maxRetries: 2,
717 | retryOnPost: true,
718 | statusCodes: [0, 502, 504],
719 | });
720 | return httpRequest(options);
721 | });
722 | mockery.enable({
723 | useCleanCache: true,
724 | warnOnUnregistered: false,
725 | });
726 |
727 | Fetcher = require('../../../libs/fetcher.client');
728 |
729 | this.fetcher = new Fetcher({
730 | retry: {
731 | interval: 350,
732 | maxRetries: 2,
733 | statusCodes: [0, 502, 504],
734 | },
735 | unsafeAllowRetry: true,
736 | });
737 | });
738 |
739 | testCrud({
740 | params,
741 | body,
742 | config,
743 | callback,
744 | resolve,
745 | reject,
746 | });
747 |
748 | after(function () {
749 | mockery.deregisterMock('./util/httpRequest');
750 | mockery.disable();
751 | });
752 | });
753 |
754 | describe('should be configurable per request', function () {
755 | before(function () {
756 | mockery.registerMock('./util/httpRequest', function (options) {
757 | expect(options.retry).to.deep.equal({
758 | interval: 350,
759 | maxRetries: 2,
760 | retryOnPost: true,
761 | statusCodes: [0, 502, 504],
762 | });
763 | return httpRequest(options);
764 | });
765 | mockery.enable({
766 | useCleanCache: true,
767 | warnOnUnregistered: false,
768 | });
769 | Fetcher = require('../../../libs/fetcher.client');
770 | this.fetcher = new Fetcher({});
771 | });
772 |
773 | testCrud({
774 | params,
775 | body,
776 | config: {
777 | retry: {
778 | interval: 350,
779 | maxRetries: 2,
780 | statusCodes: [0, 502, 504],
781 | },
782 | unsafeAllowRetry: true,
783 | },
784 | callback,
785 | resolve,
786 | reject,
787 | disableNoConfigTests: true,
788 | });
789 |
790 | after(function () {
791 | mockery.deregisterMock('./util/httpRequest');
792 | mockery.disable();
793 | });
794 | });
795 | });
796 | });
797 |
--------------------------------------------------------------------------------