From b903597e876717b5627f841cfbb530c1e6690502 Mon Sep 17 00:00:00 2001 From: nodkz Date: Sun, 12 Mar 2017 21:41:42 +0600 Subject: [PATCH] feat(BatchMiddleware): Completely rewritten batch logic as middleware BREAKING CHANGE: You should update config if you use request batching --- README.md | 209 +++++++++++--------- src/index.js | 5 +- src/middleware/batch.js | 135 +++++++++++++ src/relay/_query.js | 47 ----- src/relay/queries.js | 11 -- src/relay/queriesBatch.js | 54 ----- src/{relay/mutation.js => relayMutation.js} | 20 +- src/relayNetworkLayer.js | 32 +-- src/relayQueries.js | 58 ++++++ test/batch.test.js | 146 ++++++++++++-- 10 files changed, 461 insertions(+), 256 deletions(-) create mode 100644 src/middleware/batch.js delete mode 100644 src/relay/_query.js delete mode 100644 src/relay/queries.js delete mode 100644 src/relay/queriesBatch.js rename src/{relay/mutation.js => relayMutation.js} (83%) create mode 100644 src/relayQueries.js diff --git a/README.md b/README.md index ce9c2a5..86cbcd6 100644 --- a/README.md +++ b/README.md @@ -7,43 +7,64 @@ ReactRelayNetworkLayer [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) The `ReactRelayNetworkLayer` is a [Relay Network Layer](https://facebook.github.io/relay/docs/guides-network-layer.html) -with query batching and middleware support. +with various middlewares which can manipulate requests/responses on the fly (change auth headers, request url or perform some fallback if request fails), batch several relay request by timeout into one http request. -This NetworkLayer solves the following problems: -- If your app is making enough individual queries to be a performance problem on first page load -- If your app needs to manipulate requests/responses on the fly - change auth headers, request url or perform some fallback if request fails +`ReactRelayNetworkLayer` can be used in browser, react-native or node server for rendering. Under the hood this module uses global `fetch` method. So if your client is too old, please import explicitly proper polyfill to your code (eg. `whatwg-fetch`, `node-fetch` or `fetch-everywhere`). -Can be used in browser, react-native or node server for rendering. Under the hood this module uses global `fetch` method. So if your client is too old, please import explicitly proper polyfill to your code (eg. `whatwg-fetch`, `node-fetch` or `fetch-everywhere`). +### Migrating from v1 to v2 +In v2 was completely rewritten batch logic as middleware. All other parts stay unaffected. So if you use request batching, you should change your config: +```diff +import Relay from 'react-relay'; +import { + RelayNetworkLayer, + urlMiddleware, ++ batchMiddleware, +} from 'react-relay-network-layer'; + +Relay.injectNetworkLayer(new RelayNetworkLayer([ ++ batchMiddleware({ ++ batchUrl: (req) => '/graphql/batch', ++ }), + urlMiddleware({ + url: (req) => '/graphql', +- batchUrl: (req) => '/graphql/batch', + }), +- ], { disableBatchQuery: false })); ++ ])); +``` -Available middlewares: -- **custom inline middleware** - [see example](https://github.com/nodkz/react-relay-network-layer#example-of-injecting-networklayer-with-middlewares-on-the-client-side) below where added `credentials` and `headers` to the `fetch` method. - - `next => req => { /* your modification of 'req' object */ return next(req); }` . -- **url** - for manipulating fetch `url` on fly via thunk. Options: - - `url` - string or function(req) for single request (default: `/graphql`) - - `batchUrl` - string or function(req) for batch request, server must be prepared for such requests (default: `/graphql/batch`) -- **retry** - for request retry if the initial request fails. Options: +### Available middlewares: +- **your custom inline middleware** - [see example](https://github.com/nodkz/react-relay-network-layer#example-of-injecting-networklayer-with-middlewares-on-the-client-side) below where added `credentials` and `headers` to the `fetch` method. + - `next => req => { /* your modification of 'req' object */ return next(req); }` +- **urlMiddleware** - for manipulating fetch `url` on fly via thunk. + - `url` - string or function(req) for single request (default: `/graphql`) +- **batchMiddleware** - gather some period of time relay-requests and sends it as one http-request + - `batchUrl` - string or function(requestMap). Url of the server endpoint for batch request execution (default: `/graphql/batch`) + - `batchTimeout` - integer in milliseconds, period of time for gathering multiple requests before sending them to the server (default: `0`) + - `allowMutations` - by default batching disabled for mutations, you may enable it passing `true` (default: `false`) +- **retryMiddleware** - for request retry if the initial request fails. - `fetchTimeout` - number in milliseconds that defines in how much time will request timeout after it has been sent to the server (default: `15000`). - `retryDelays` - array of millisecond that defines the values on which retries are based on (default: `[1000, 3000]`). - `statusCodes` - array of response status codes which will fire up retryMiddleware (default: `status < 200 or status > 300`). - `allowMutations` - by default retries disabled for mutations, you may allow process retries for them passing `true` (default: `false`) - `forceRetry` - function(cb, delay), when request is delayed for next retry, middleware will call this function and pass to it a callback and delay time. When you call this callback, middleware will proceed request immediately (default: `false`). -- **auth** - for adding auth token, and refreshing it if gets 401 response from server. Options: +- **authMiddleware** - for adding auth token, and refreshing it if gets 401 response from server. - `token` - string or function(req) which returns token. If function is provided, then it will be called for every request (so you may change tokens on fly). - `tokenRefreshPromise`: - function(req, err) which must return promise with new token, called only if server returns 401 status code and this function is provided. - `allowEmptyToken` - allow made a request without Authorization header if token is empty (default: `false`). - `prefix` - prefix before token (default: `'Bearer '`). - `header` - name of the HTTP header to pass the token in (default: `'Authorization'`). - If you use `auth` middleware with `retry`, `retry` must be used before `auth`. Eg. if token expired when retries apply, then `retry` can call `auth` middleware again. -- **logger** - for logging requests and responses. Options: +- **loggerMiddleware** - for logging requests and responses. - `logger` - log function (default: `console.log.bind(console, '[RELAY-NETWORK]')`) - If you use `Relay@^0.9.0` you may turn on relay's internal [extended mutation debugger](https://twitter.com/steveluscher/status/738101549591732225). For this you should open browser console and type `__DEV__=true`. With webpack you may use `webpack.BannerPlugin('__DEV__=true;', {raw: true})` or `webpack.DefinePlugin({__DEV__: true})`. - If you use `Relay@^0.8.0` you may turn on [internal Relay requests debugger](https://cloud.githubusercontent.com/assets/1946920/15735688/688ccabe-28bc-11e6-82e2-db644eb698b0.png): `import RelayNetworkDebug from 'react-relay/lib/RelayNetworkDebug'; RelayNetworkDebug.init();` -- **perf** - simple time measure for network request. Options: +- **perfMiddleware** - simple time measure for network request. - `logger` - log function (default: `console.log.bind(console, '[RELAY-NETWORK]')`) -- **gqErrors** - display `errors` data to console from graphql response. If you want see stackTrace for errors, you should provide `formatError` to `express-graphql` (see example below where `graphqlServer` accept `formatError` function). Options: +- **gqErrorsMiddleware** - display `errors` data to console from graphql response. If you want see stackTrace for errors, you should provide `formatError` to `express-graphql` (see example below where `graphqlServer` accept `formatError` function). - `logger` - log function (default: `console.error.bind(console)`) - `prefix` - prefix message (default: `[RELAY-NETWORK] GRAPHQL SERVER ERROR:`) -- **defer** - _experimental_ Right now `deferMiddleware()` just set `defer` as supported option for Relay. So this middleware allow to community play with `defer()` in cases, which was [described by @wincent](https://github.com/facebook/relay/issues/288#issuecomment-199510058). +- **deferMiddleware** - _experimental_ Right now `deferMiddleware()` just set `defer` as supported option for Relay. So this middleware allow to community play with `defer()` in cases, which was [described by @wincent](https://github.com/facebook/relay/issues/288#issuecomment-199510058). [CHANGELOG](https://github.com/nodkz/react-relay-network-layer/blob/master/CHANGELOG.md) @@ -56,88 +77,30 @@ OR npm install react-relay-network-layer --save ``` - -Part 1: Batching several requests into one -========================================== - -Joseph Savona [wrote](https://github.com/facebook/relay/issues/1058#issuecomment-213592051): For legacy reasons, Relay splits "plural" root queries into individual queries. In general we want to diff each root value separately, since different fields may be missing for different root values. - -Also if you use [react-relay-router](https://github.com/relay-tools/react-router-relay) and have multiple root queries in one route pass, you may notice that default network layer will produce several http requests. - -So for avoiding multiple http-requests, the `ReactRelayNetworkLayer` is the right way to combine it in single http-request. - -### Example how to enable batching -#### ...on server -Firstly, you should prepare **server** to proceed the batch request: - -```js -import express from 'express'; -import graphqlHTTP from 'express-graphql'; -import { graphqlBatchHTTPWrapper } from 'react-relay-network-layer'; -import bodyParser from 'body-parser'; -import myGraphqlSchema from './graphqlSchema'; - -const port = 3000; -const server = express(); - -// setup standart `graphqlHTTP` express-middleware -const graphqlServer = graphqlHTTP({ - schema: myGraphqlSchema, - formatError: (error) => ({ // better errors for development. `stack` used in `gqErrors` middleware - message: error.message, - stack: process.env.NODE_ENV === 'development' ? error.stack.split('\n') : null, - }), -}); - -// declare route for batch query -server.use('/graphql/batch', - bodyParser.json(), - graphqlBatchHTTPWrapper(graphqlServer) -); - -// declare standard graphql route -server.use('/graphql', - graphqlServer -); - -server.listen(port, () => { - console.log(`The server is running at http://localhost:${port}/`); -}); -``` -[More complex example](https://github.com/nodkz/react-relay-network-layer/blob/master/examples/dataLoaderPerBatchRequest.js) of how you can use a single [DataLoader](https://github.com/facebook/dataloader) for all (batched) queries within a one HTTP-request. - -If you are on Koa@2, [koa-graphql-batch](https://github.com/mattecapu/koa-graphql-batch) provides the same functionality as `graphqlBatchHTTPWrapper` (see its docs for usage example). - -#### ...on client -And right after server side ready to accept batch queries, you may enable batching on the **client**: - -```js -Relay.injectNetworkLayer(new RelayNetworkLayer([ - urlMiddleware({ - url: '/graphql', - batchUrl: '/graphql/batch', // <--- route for batch queries - }), -], { disableBatchQuery: false })); // <--- set to FALSE, or may remove `disableBatchQuery` option at all -``` - -### How batching works internally -Internally batching in NetworkLayer prepare list of queries `[ {id, query, variables}, ...]` sends it to server. And server returns list of responces `[ {id, payload}, ...]`, (where `id` is the same value as client requested for identifying which data goes with which query, and `payload` is standard response of GraphQL server: `{ data, error }`). - - -Part 2: Middlewares -==================== +Middlewares +=========== ### Example of injecting NetworkLayer with middlewares on the **client side**. ```js import Relay from 'react-relay'; import { - RelayNetworkLayer, retryMiddleware, urlMiddleware, authMiddleware, loggerMiddleware, - perfMiddleware, gqErrorsMiddleware + RelayNetworkLayer, + urlMiddleware, + batchMiddleware, + loggerMiddleware, + gqErrorsMiddleware, + perfMiddleware, + retryMiddleware, + authMiddleware, } from 'react-relay-network-layer'; Relay.injectNetworkLayer(new RelayNetworkLayer([ urlMiddleware({ url: (req) => '/graphql', }), + batchMiddleware({ + batchUrl: (reqestMap) => '/graphql/batch', + batchTimeout: 10, + }), loggerMiddleware(), gqErrorsMiddleware(), perfMiddleware(), @@ -176,7 +139,7 @@ Relay.injectNetworkLayer(new RelayNetworkLayer([ req.credentials = 'same-origin'; // provide CORS policy to XHR request in fetch method return next(req); } -], { disableBatchQuery: true })); +])); ``` ### How middlewares work internally @@ -220,6 +183,72 @@ Middlewares use LIFO (last in, first out) stack. Or simply put - use `compose` f - chain to `resPromise.then(res => res.json())` and pass this promise for resolving/rejecting Relay requests. +Batching several requests into one +================================== + +Joseph Savona [wrote](https://github.com/facebook/relay/issues/1058#issuecomment-213592051): For legacy reasons, Relay splits "plural" root queries into individual queries. In general we want to diff each root value separately, since different fields may be missing for different root values. + +Also if you use [react-relay-router](https://github.com/relay-tools/react-router-relay) and have multiple root queries in one route pass, you may notice that default network layer will produce several http requests. + +So for avoiding multiple http-requests, the `ReactRelayNetworkLayer` is the right way to combine it in single http-request. + +### Example how to enable batching +#### ...on server +Firstly, you should prepare **server** to proceed the batch request: + +```js +import express from 'express'; +import graphqlHTTP from 'express-graphql'; +import { graphqlBatchHTTPWrapper } from 'react-relay-network-layer'; +import bodyParser from 'body-parser'; +import myGraphqlSchema from './graphqlSchema'; + +const port = 3000; +const server = express(); + +// setup standart `graphqlHTTP` express-middleware +const graphqlServer = graphqlHTTP({ + schema: myGraphqlSchema, + formatError: (error) => ({ // better errors for development. `stack` used in `gqErrors` middleware + message: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack.split('\n') : null, + }), +}); + +// declare route for batch query +server.use('/graphql/batch', + bodyParser.json(), + graphqlBatchHTTPWrapper(graphqlServer) +); + +// declare standard graphql route +server.use('/graphql', + graphqlServer +); + +server.listen(port, () => { + console.log(`The server is running at http://localhost:${port}/`); +}); +``` +[More complex example](https://github.com/nodkz/react-relay-network-layer/blob/master/examples/dataLoaderPerBatchRequest.js) of how you can use a single [DataLoader](https://github.com/facebook/dataloader) for all (batched) queries within a one HTTP-request. + +If you are on Koa@2, [koa-graphql-batch](https://github.com/mattecapu/koa-graphql-batch) provides the same functionality as `graphqlBatchHTTPWrapper` (see its docs for usage example). + +#### ...on client +And right after server side ready to accept batch queries, you may enable batching on the **client**: + +```js +Relay.injectNetworkLayer(new RelayNetworkLayer([ + batchMiddleware({ + batchUrl: '/graphql/batch', // <--- route for batch queries + }), +])); +``` + +### How batching works internally +Internally batching in NetworkLayer prepare list of queries `[ {id, query, variables}, ...]` sends it to server. And server returns list of responces `[ {id, payload}, ...]`, (where `id` is the same value as client requested for identifying which data goes with which query, and `payload` is standard response of GraphQL server: `{ data, error }`). + + Recommended modules ========== - **[babel-plugin-transform-relay-hot](https://github.com/nodkz/babel-plugin-transform-relay-hot)** - Babel 6 plugin for transforming `Relay.QL` tagged templates via the GraphQL json schema file. Each time when schema file was changed, the wrapper updates instance of standard `babelRelayPlugin` with new schema without completely restarting dev server. diff --git a/src/index.js b/src/index.js index 89c534d..3c98232 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import RelayNetworkLayer from './relayNetworkLayer'; +import batchMiddleware from './middleware/batch'; import retryMiddleware from './middleware/retry'; import urlMiddleware from './middleware/url'; import authMiddleware from './middleware/auth'; @@ -6,10 +7,12 @@ import perfMiddleware from './middleware/perf'; import loggerMiddleware from './middleware/logger'; import gqErrorsMiddleware from './middleware/gqErrors'; import deferMiddleware from './middleware/defer'; -import graphqlBatchHTTPWrapper from './express-middleware/graphqlBatchHTTPWrapper'; +import graphqlBatchHTTPWrapper + from './express-middleware/graphqlBatchHTTPWrapper'; export { RelayNetworkLayer, + batchMiddleware, retryMiddleware, urlMiddleware, authMiddleware, diff --git a/src/middleware/batch.js b/src/middleware/batch.js new file mode 100644 index 0000000..0257b22 --- /dev/null +++ b/src/middleware/batch.js @@ -0,0 +1,135 @@ +/* eslint-disable no-param-reassign */ + +import { isFunction } from '../utils'; + +export default function batchMiddleware(opts = {}) { + const batchTimeout = opts.batchTimeout || 0; // 0 is the same as nextTick in nodeJS + const allowMutations = opts.allowMutations || false; + const batchUrl = opts.batchUrl || '/graphql/batch'; + const singleton = {}; + + return next => + req => { + if (req.relayReqType === 'mutation' && !allowMutations) { + return next(req); + } + + return passThroughBatch(req, next, { + batchTimeout, + batchUrl, + singleton, + }); + }; +} + +function passThroughBatch(req, next, opts) { + const { singleton } = opts; + + if (!singleton.batcher || !singleton.batcher.acceptRequests) { + singleton.batcher = prepareNewBatcher(next, opts); + } + + // here we can check batcher body size + // and if needed splitting - create new batcher: + // singleton.batcher = prepareNewBatcher(next, opts); + singleton.batcher.bodySize += req.body.length; + + // queue request + return new Promise((resolve, reject) => { + singleton.batcher.requestMap[req.relayReqId] = { req, resolve, reject }; + }); +} + +function prepareNewBatcher(next, opts) { + const batcher = { + bodySize: 0, + requestMap: {}, + acceptRequests: true, + }; + + setTimeout( + () => { + batcher.acceptRequests = false; + sendRequests(batcher.requestMap, next, opts).then(() => { + // check that server returns responses for all requests + Object.keys(batcher.requestMap).forEach(id => { + if (!batcher.requestMap[id].done) { + batcher.requestMap[id].reject( + new Error( + `Server does not return response for request with id ${id} \n` + + `eg. { "id": "${id}", "data": {} }` + ) + ); + } + }); + }); + }, + opts.batchTimeout + ); + + return batcher; +} + +function sendRequests(requestMap, next, opts) { + const ids = Object.keys(requestMap); + + if (ids.length === 1) { + // SEND AS SINGLE QUERY + const request = requestMap[ids[0]]; + + return next(request.req).then(res => { + request.done = true; + request.resolve(res); + }); + } else if (ids.length > 1) { + // SEND AS BATCHED QUERY + const url = isFunction(opts.batchUrl) + ? opts.batchUrl(requestMap) + : opts.batchUrl; + + const req = { + url, + relayReqId: `BATCH_QUERY:${ids.join(':')}`, + relayReqObj: requestMap, + relayReqType: 'batch-query', + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: `[${ids.map(id => requestMap[id].req.body).join(',')}]`, + }; + + return next(req) + .then(batchResponse => { + if (!batchResponse || !Array.isArray(batchResponse.json)) { + throw new Error('Wrong response from server'); + } + + batchResponse.json.forEach(res => { + if (!res) return; + const request = requestMap[res.id]; + if (request) { + if (res.payload) { + request.done = true; + request.resolve( + Object.assign({}, batchResponse, { json: res.payload }) + ); + return; + } + // compatibility with graphene-django and apollo-server batch format + request.done = true; + request.resolve(Object.assign({}, batchResponse, { json: res })); + } + }); + }) + .catch(e => { + ids.forEach(id => { + requestMap[id].done = true; + requestMap[id].reject(e); + }); + }); + } + + return Promise.resolve(); +} diff --git a/src/relay/_query.js b/src/relay/_query.js deleted file mode 100644 index 1382d57..0000000 --- a/src/relay/_query.js +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable no-param-reassign, prefer-template */ - -import formatRequestErrors from '../formatRequestErrors'; - -export function queryPre(relayRequest) { - const req = { - relayReqId: relayRequest.getID(), - relayReqObj: relayRequest, - relayReqType: 'query', - method: 'POST', - headers: { - Accept: '*/*', - 'Content-Type': 'application/json', - }, - }; - - req.body = JSON.stringify({ - query: relayRequest.getQueryString(), - variables: relayRequest.getVariables(), - }); - - return req; -} - - -export function queryPost(relayRequest, fetchPromise) { - return fetchPromise.then(payload => { - if ({}.hasOwnProperty.call(payload, 'errors')) { - const error = new Error( - 'Server request for query `' + relayRequest.getDebugName() + '` ' + - 'failed for the following reasons:\n\n' + - formatRequestErrors(relayRequest, payload.errors) - ); - error.source = payload; - relayRequest.reject(error); - } else if (!{}.hasOwnProperty.call(payload, 'data')) { - relayRequest.reject(new Error( - 'Server response was missing for query `' + relayRequest.getDebugName() + - '`.' - )); - } else { - relayRequest.resolve({ response: payload.data }); - } - }).catch( - error => relayRequest.reject(error) - ); -} diff --git a/src/relay/queries.js b/src/relay/queries.js deleted file mode 100644 index 5fc282b..0000000 --- a/src/relay/queries.js +++ /dev/null @@ -1,11 +0,0 @@ -import { queryPre, queryPost } from './_query'; - -export default function queries(relayRequestList, fetchWithMiddleware) { - return Promise.all( - relayRequestList.map(relayRequest => { - const req = queryPre(relayRequest); - const fetchPromise = fetchWithMiddleware(req); - return queryPost(relayRequest, fetchPromise); - }) - ); -} diff --git a/src/relay/queriesBatch.js b/src/relay/queriesBatch.js deleted file mode 100644 index 145248e..0000000 --- a/src/relay/queriesBatch.js +++ /dev/null @@ -1,54 +0,0 @@ -import { queryPost } from './_query'; - -export default function queriesBatch(relayRequestList, fetchWithMiddleware) { - const requestMap = {}; - relayRequestList.forEach((req) => { - const reqId = req.getID(); - requestMap[reqId] = req; - }); - - const req = { - relayReqId: `BATCH_QUERY:${Object.keys(requestMap).join(':')}`, - relayReqObj: relayRequestList, - relayReqType: 'batch-query', - method: 'POST', - headers: { - Accept: '*/*', - 'Content-Type': 'application/json', - }, - }; - - req.body = JSON.stringify( - Object.keys(requestMap).map((id) => ({ - id, - query: requestMap[id].getQueryString(), - variables: requestMap[id].getVariables(), - })) - ); - - return fetchWithMiddleware(req) - .then(batchResponses => { - batchResponses.forEach((res) => { - if (!res) return; - const relayRequest = requestMap[res.id]; - - if (relayRequest) { - queryPost( - relayRequest, - new Promise(resolve => { - if (res.payload) { - resolve(res.payload); - return; - } - // compatibility with graphene-django and apollo-server batch format - resolve(res); - }) - ); - } - }); - }).catch(e => { - return Promise.all(relayRequestList.map(relayRequest => { - return relayRequest.reject(e); - })); - }); -} diff --git a/src/relay/mutation.js b/src/relayMutation.js similarity index 83% rename from src/relay/mutation.js rename to src/relayMutation.js index ab883a9..1387845 100644 --- a/src/relay/mutation.js +++ b/src/relayMutation.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign, no-use-before-define, prefer-template */ -import formatRequestErrors from '../formatRequestErrors'; +import formatRequestErrors from './formatRequestErrors'; export default function mutation(relayRequest, fetchWithMiddleware) { const req = { @@ -20,26 +20,25 @@ export default function mutation(relayRequest, fetchWithMiddleware) { .then(payload => { if ({}.hasOwnProperty.call(payload, 'errors')) { const error = new Error( - 'Server request for mutation `' + relayRequest.getDebugName() + '` ' + - 'failed for the following reasons:\n\n' + - formatRequestErrors(relayRequest, payload.errors) + 'Server request for mutation `' + + relayRequest.getDebugName() + + '` ' + + 'failed for the following reasons:\n\n' + + formatRequestErrors(relayRequest, payload.errors) ); error.source = payload; relayRequest.reject(error); } else { relayRequest.resolve({ response: payload.data }); } - }).catch( - error => relayRequest.reject(error) - ); + }) + .catch(error => relayRequest.reject(error)); } - function _hasFiles(relayRequest) { return !!(relayRequest.getFiles && relayRequest.getFiles()); } - function _mutationWithFiles(relayRequest) { const req = { headers: {}, @@ -56,7 +55,7 @@ function _mutationWithFiles(relayRequest) { formData.append('variables', JSON.stringify(relayRequest.getVariables())); Object.keys(files).forEach(filename => { if (Array.isArray(files[filename])) { - files[filename].forEach((file) => { + files[filename].forEach(file => { formData.append(filename, file); }); } else { @@ -69,7 +68,6 @@ function _mutationWithFiles(relayRequest) { return req; } - function _mutation(relayRequest) { const req = { headers: { diff --git a/src/relayNetworkLayer.js b/src/relayNetworkLayer.js index 7b4e806..f2c0566 100644 --- a/src/relayNetworkLayer.js +++ b/src/relayNetworkLayer.js @@ -1,13 +1,13 @@ -import queries from './relay/queries'; -import queriesBatch from './relay/queriesBatch'; -import mutation from './relay/mutation'; +import queries from './relayQueries'; +import mutation from './relayMutation'; import fetchWrapper from './fetchWrapper'; - export default class RelayNetworkLayer { constructor(middlewares, options) { this._options = options; - this._middlewares = Array.isArray(middlewares) ? middlewares : [middlewares]; + this._middlewares = Array.isArray(middlewares) + ? middlewares + : [middlewares]; this._supportedOptions = []; this._middlewares.forEach(mw => { @@ -23,31 +23,19 @@ export default class RelayNetworkLayer { this.supports = this.supports.bind(this); this.sendQueries = this.sendQueries.bind(this); this.sendMutation = this.sendMutation.bind(this); - this._fetchWithMiddleware = this._fetchWithMiddleware.bind(this); - this._isBatchQueriesDisabled = this._isBatchQueriesDisabled.bind(this); } supports(...options) { - return options.every(option => this._supportedOptions.indexOf(option) !== -1); + return options.every( + option => this._supportedOptions.indexOf(option) !== -1 + ); } sendQueries(requests) { - if (requests.length > 1 && !this._isBatchQueriesDisabled()) { - return queriesBatch(requests, this._fetchWithMiddleware); - } - - return queries(requests, this._fetchWithMiddleware); + return queries(requests, req => fetchWrapper(req, this._middlewares)); } sendMutation(request) { - return mutation(request, this._fetchWithMiddleware); - } - - _fetchWithMiddleware(req) { - return fetchWrapper(req, this._middlewares); - } - - _isBatchQueriesDisabled() { - return this._options && this._options.disableBatchQuery; + return mutation(request, req => fetchWrapper(req, this._middlewares)); } } diff --git a/src/relayQueries.js b/src/relayQueries.js new file mode 100644 index 0000000..e7e7ca6 --- /dev/null +++ b/src/relayQueries.js @@ -0,0 +1,58 @@ +/* eslint-disable no-param-reassign, prefer-template */ + +import formatRequestErrors from './formatRequestErrors'; + +export default function queries(relayRequestList, fetchWithMiddleware) { + return Promise.all( + relayRequestList.map(relayRequest => { + const req = { + relayReqId: relayRequest.getID(), + relayReqObj: relayRequest, + relayReqType: 'query', + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: prepareRequestBody(relayRequest), + }; + return queryPost(relayRequest, fetchWithMiddleware(req)); + }) + ); +} + +export function prepareRequestBody(relayRequest) { + return JSON.stringify({ + id: relayRequest.getID(), + query: relayRequest.getQueryString(), + variables: relayRequest.getVariables(), + }); +} + +export function queryPost(relayRequest, fetchPromise) { + return fetchPromise + .then(payload => { + if ({}.hasOwnProperty.call(payload, 'errors')) { + const error = new Error( + 'Server request for query `' + + relayRequest.getDebugName() + + '` ' + + 'failed for the following reasons:\n\n' + + formatRequestErrors(relayRequest, payload.errors) + ); + error.source = payload; + throw new Error(error); + } else if (!{}.hasOwnProperty.call(payload, 'data')) { + throw new Error( + 'Server response.data was missing for query `' + + relayRequest.getDebugName() + + '`.' + ); + } + return relayRequest.resolve({ response: payload.data }); + }) + .catch(error => { + relayRequest.reject(error); + throw error; + }); +} diff --git a/test/batch.test.js b/test/batch.test.js index b8e4fb4..4396788 100644 --- a/test/batch.test.js +++ b/test/batch.test.js @@ -1,23 +1,57 @@ import { assert } from 'chai'; import fetchMock from 'fetch-mock'; -import { RelayNetworkLayer } from '../src'; +import { RelayNetworkLayer, batchMiddleware } from '../src'; import { mockReq } from './testutils'; describe('Batch tests', () => { - const middlewares = []; - const rnl = new RelayNetworkLayer(middlewares); + const rnl = new RelayNetworkLayer([batchMiddleware()]); - afterEach(() => { + beforeEach(() => { fetchMock.restore(); }); + it('should make a successfull single request', () => { + fetchMock.post('/graphql', { data: {} }); + return assert.isFulfilled(rnl.sendQueries([mockReq()])); + }); + it('should make a successfull batch request', () => { - fetchMock.post('/graphql/batch', [ - {}, - {}, - ]); + fetchMock.mock({ + matcher: '/graphql/batch', + response: { + status: 200, + body: [{ id: 1, data: {} }, { id: 2, data: {} }], + }, + method: 'POST', + }); - assert.isFulfilled(rnl.sendQueries([mockReq(), mockReq()])); + return assert.isFulfilled(rnl.sendQueries([mockReq(1), mockReq(2)])); + }); + + it('should reject if server does not return response for request', () => { + fetchMock.mock({ + matcher: '/graphql/batch', + response: { + status: 200, + body: [{ data: {} }, { data: {} }], + }, + method: 'POST', + }); + + const req1 = mockReq(1); + req1.reject = err => { + assert(err instanceof Error); + assert(/Server does not return response for request/.test(err.message)); + }; + const req2 = mockReq(2); + req2.reject = err => { + assert(err instanceof Error); + assert(/Server does not return response for request/.test(err.message)); + }; + return assert.isRejected( + rnl.sendQueries([req1, req2]), + /Server does not return response for request/ + ); }); it('should handle network failure', () => { @@ -28,7 +62,10 @@ describe('Batch tests', () => { }, method: 'POST', }); - assert.isRejected(rnl.sendQueries([mockReq(), mockReq()]), /Network connection error/); + return assert.isRejected( + rnl.sendQueries([mockReq(), mockReq()]), + /Network connection error/ + ); }); it('should handle server errors', () => { @@ -40,9 +77,7 @@ describe('Batch tests', () => { { id: 1, payload: { - errors: [ - { location: 1, message: 'major error' }, - ], + errors: [{ location: 1, message: 'major error' }], }, }, { id: 2, payload: { data: {} } }, @@ -52,11 +87,11 @@ describe('Batch tests', () => { }); const req1 = mockReq(1); - req1.reject = (err) => { + req1.reject = err => { assert(err instanceof Error, 'should be an error'); }; const req2 = mockReq(2); - assert.isFulfilled(rnl.sendQueries([req1, req2])); + return assert.isRejected(rnl.sendQueries([req1, req2])); }); it('should handle responces without payload wrapper', () => { @@ -67,9 +102,7 @@ describe('Batch tests', () => { body: [ { id: 1, - errors: [ - { location: 1, message: 'major error' }, - ], + errors: [{ location: 1, message: 'major error' }], }, { id: 2, data: {} }, ], @@ -78,10 +111,83 @@ describe('Batch tests', () => { }); const req1 = mockReq(1); - req1.reject = (err) => { + req1.reject = err => { assert(err instanceof Error, 'should be an error'); }; const req2 = mockReq(2); - assert.isFulfilled(rnl.sendQueries([req1, req2])); + return assert.isRejected(rnl.sendQueries([req1, req2])); + }); + + describe('option `batchTimeout`', () => { + const rnl2 = new RelayNetworkLayer([batchMiddleware({ batchTimeout: 50 })]); + + beforeEach(() => { + fetchMock.restore(); + }); + + it('should gather different requests into one batch request', () => { + fetchMock.mock({ + matcher: '/graphql/batch', + response: { + status: 200, + body: [{ id: 1, data: {} }, { id: 2, data: {} }, { id: 3, data: {} }], + }, + method: 'POST', + }); + + rnl2.sendQueries([mockReq(1)]); + setTimeout(() => rnl2.sendQueries([mockReq(2)]), 30); + + return assert.isFulfilled(rnl2.sendQueries([mockReq(3)])).then(() => { + const reqs = fetchMock.calls('/graphql/batch'); + assert.equal(reqs.length, 1); + assert.equal( + reqs[0][1].body, + '[{"id":1,"query":"{}","variables":{}},{"id":2,"query":"{}","variables":{}},{"id":3,"query":"{}","variables":{}}]' + ); + }); + }); + + it('should gather different requests into two batch request', () => { + fetchMock.mock({ + matcher: '/graphql/batch', + response: { + status: 200, + body: [ + { id: 1, data: {} }, + { id: 2, data: {} }, + { id: 3, data: {} }, + { id: 4, data: {} }, + ], + }, + method: 'POST', + }); + + rnl2.sendQueries([mockReq(1)]); + + setTimeout(() => rnl2.sendQueries([mockReq(2)]), 60); + setTimeout(() => rnl2.sendQueries([mockReq(3)]), 70); + + return assert.isFulfilled(rnl2.sendQueries([mockReq(4)])).then(() => { + return new Promise(resolve => { + setTimeout( + () => { + const reqs = fetchMock.calls('/graphql/batch'); + assert.equal(reqs.length, 2); + assert.equal( + reqs[0][1].body, + '[{"id":1,"query":"{}","variables":{}},{"id":4,"query":"{}","variables":{}}]' + ); + assert.equal( + reqs[1][1].body, + '[{"id":2,"query":"{}","variables":{}},{"id":3,"query":"{}","variables":{}}]' + ); + resolve(); + }, + 100 + ); + }); + }); + }); }); });