├── 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 | [![npm version](https://badge.fury.io/js/fetchr.svg)](http://badge.fury.io/js/fetchr) 8 | ![Build Status](https://github.com/yahoo/fetchr/actions/workflows/node.js.yml/badge.svg) 9 | [![Coverage Status](https://coveralls.io/repos/yahoo/fetchr/badge.png?branch=master)](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 | --------------------------------------------------------------------------------