Skip to content

Commit

Permalink
feat: add progress middleware (thanks @AnotherHermit)
Browse files Browse the repository at this point in the history
* Add Progress and Format as raw/format middleware

* Extend NetworkLayer and fetch with raw middleware option

* Fix tests

* Fix whitespace in eslintrc

* Update progress mw with simpler check for support

* refactor after review

* Update README
  • Loading branch information
AnotherHermit authored and nodkz committed May 18, 2018
1 parent 650e6cd commit 525e256
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 23 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"$FlowFixMe": true,
"Blob": true,
"Class": true,
"Response": true,
"window": true,
"$PropertyType": true
}
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ import { RelayNetworkLayer } from 'react-relay-network-modern/es';
* **errorMiddleware** - 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:`)
* **progressMiddleware** - enable onProgress callback for modern browsers with support for Stream API.
* `onProgress` - on progress callback function (`function(bytesCurrent: number, bytesTotal: number | null) => void`, total size will be null if size header is not set)
* `sizeHeader` - response header with total size of response (default: `Content-Length`, useful when `Transfer-Encoding: chunked` is set)

### Standalone package middlewares:

Expand All @@ -122,6 +125,7 @@ import {
retryMiddleware,
authMiddleware,
cacheMiddleware,
progressMiddleware,
} from 'react-relay-network-modern';

const network = new RelayNetworkLayer(
Expand Down Expand Up @@ -163,6 +167,11 @@ const network = new RelayNetworkLayer(
.catch(err => console.log('[client.js] ERROR can not refresh token', err));
},
}),
progressMiddleware({
onProgress: (current, total) => {
console.log('Downloaded: ' + current + ' B, total: ' + total + ' B');
},
}),

// example of the custom inline middleware
next => async req => {
Expand Down Expand Up @@ -229,6 +238,8 @@ return new RRNL.RelayNetworkLayer([

Middlewares on bottom layer use [fetch](https://github.com/github/fetch) method. So `req` is compliant with a `fetch()` options. And `res` can be obtained via `resPromise.then(res => ...)`, which returned by `fetch()`.

Middleware that needs access to the raw response body from fetch (before it has been consumed) can set `isRawMiddleware = true`, see `progressMiddleware` for example. It is important to note that `response.body` can only be consumed once, so make sure to `clone()` the response first.

Middlewares have 3 phases:

* `setup phase`, which runs only once, when middleware added to the NetworkLayer
Expand Down
12 changes: 10 additions & 2 deletions src/RelayNetworkLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import fetchWithMiddleware from './fetchWithMiddleware';
import type {
Middleware,
MiddlewareSync,
MiddlewareRaw,
FetchFunction,
FetchHookFunction,
SubscribeFunction,
Expand All @@ -20,14 +21,19 @@ type RelayNetworkLayerOpts = {|

export default class RelayNetworkLayer {
_middlewares: Middleware[];
_rawMiddlewares: MiddlewareRaw[];
_middlewaresSync: RNLExecuteFunction[];
execute: RNLExecuteFunction;
+fetchFn: FetchFunction;
+subscribeFn: ?SubscribeFunction;
+noThrow: boolean;

constructor(middlewares: Array<?Middleware | MiddlewareSync>, opts?: RelayNetworkLayerOpts) {
constructor(
middlewares: Array<?Middleware | MiddlewareSync | MiddlewareRaw>,
opts?: RelayNetworkLayerOpts
) {
this._middlewares = [];
this._rawMiddlewares = [];
this._middlewaresSync = [];
this.noThrow = false;

Expand All @@ -36,6 +42,8 @@ export default class RelayNetworkLayer {
if (mw) {
if (mw.execute) {
this._middlewaresSync.push(mw.execute);
} else if (mw.isRawMiddleware) {
this._rawMiddlewares.push(mw);
} else {
this._middlewares.push(mw);
}
Expand All @@ -59,7 +67,7 @@ export default class RelayNetworkLayer {
}

const req = new RelayRequest(operation, variables, cacheConfig, uploadables);
return fetchWithMiddleware(req, this._middlewares, this.noThrow);
return fetchWithMiddleware(req, this._middlewares, this._rawMiddlewares, this.noThrow);
};

const network = Network.create(this.fetchFn, this.subscribeFn);
Expand Down
4 changes: 2 additions & 2 deletions src/RelayResponse.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* @flow */

import type { PayloadData } from './definition';
import type { PayloadData, FetchResponse } from './definition';

export default class RelayResponse {
_res: any; // response from low-level method, eg. fetch
Expand All @@ -16,7 +16,7 @@ export default class RelayResponse {
text: ?string;
json: mixed;

static async createFromFetch(res: Object): Promise<RelayResponse> {
static async createFromFetch(res: FetchResponse): Promise<RelayResponse> {
const r = new RelayResponse();
r._res = res;
r.ok = res.ok;
Expand Down
48 changes: 48 additions & 0 deletions src/__tests__/RelayNetworkLayer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,52 @@ describe('RelayNetworkLayer', () => {
expect(asyncMW).toHaveBeenCalled();
});
});

it('should correctly call raw middlewares', async () => {
fetchMock.mock({
matcher: '/graphql',
response: {
status: 200,
body: {
data: { text: 'response' },
},
sendAsJson: true,
},
method: 'POST',
});

const regularMiddleware = next => async req => {
(req: any).fetchOpts.headers.reqId += ':regular';
const res: any = await next(req);
res.data.text += ':regular';
return res;
};

const createRawMiddleware = (id: number): any => {
const rawMiddleware = next => async req => {
(req: any).fetchOpts.headers.reqId += `:raw${id}`;
const res: any = await next(req);
const parentJsonFN = res.json;
res.json = async () => {
const json = await parentJsonFN.bind(res)();
json.data.text += `:raw${id}`;
return json;
};
return res;
};
rawMiddleware.isRawMiddleware = true;
return rawMiddleware;
};

// rawMiddlewares should be called the last
const network = new RelayNetworkLayer([
createRawMiddleware(1),
createRawMiddleware(2),
regularMiddleware,
]);
const observable: any = network.execute(mockOperation, {}, {});
const result = await observable.toPromise();
expect(fetchMock.lastOptions().headers.reqId).toEqual('undefined:regular:raw1:raw2');
expect(result.response.data).toEqual({ text: 'undefined:raw2:raw1:regular' });
});
});
26 changes: 15 additions & 11 deletions src/__tests__/fetchWithMiddleware-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('fetchWithMiddleware', () => {
it('should make a successfull request without middlewares', async () => {
fetchMock.post('/graphql', { id: 1, data: { user: 123 } });
const req = new RelayRequest(({}: any), {}, {}, null);
const res = await fetchWithMiddleware(req, []);
const res = await fetchWithMiddleware(req, [], []);
expect(res.data).toEqual({ user: 123 });
});

Expand All @@ -37,11 +37,15 @@ describe('fetchWithMiddleware', () => {
reqId: 'request',
};

const res: any = await fetchWithMiddleware(req, [
numPlus5,
numMultiply10, // should be last, when changing request
// should be first, when changing response
]);
const res: any = await fetchWithMiddleware(
req,
[
numPlus5,
numMultiply10, // should be last, when changing request
// should be first, when changing response
],
[]
);
expect(res.data.text).toEqual('response:mw2:mw1');
expect(fetchMock.lastOptions().headers.reqId).toEqual('request:mw1:mw2');
});
Expand All @@ -58,7 +62,7 @@ describe('fetchWithMiddleware', () => {

expect.assertions(2);
try {
await fetchWithMiddleware(req, []);
await fetchWithMiddleware(req, [], []);
} catch (e) {
expect(e instanceof Error).toBeTruthy();
expect(e.toString()).toMatch('Network connection error');
Expand All @@ -81,7 +85,7 @@ describe('fetchWithMiddleware', () => {

expect.assertions(2);
try {
await fetchWithMiddleware(req, []);
await fetchWithMiddleware(req, [], []);
} catch (e) {
expect(e instanceof Error).toBeTruthy();
expect(e.toString()).toMatch('major error');
Expand All @@ -103,7 +107,7 @@ describe('fetchWithMiddleware', () => {
const req = new RelayRequest(({}: any), {}, {}, null);

expect.assertions(1);
const res = await fetchWithMiddleware(req, [], true);
const res = await fetchWithMiddleware(req, [], [], true);
expect(res.errors).toEqual([{ location: 1, message: 'major error' }]);
});

Expand All @@ -121,7 +125,7 @@ describe('fetchWithMiddleware', () => {

expect.assertions(2);
try {
await fetchWithMiddleware(req, []);
await fetchWithMiddleware(req, [], []);
} catch (e) {
expect(e instanceof Error).toBeTruthy();
expect(e.toString()).toMatch('Something went completely wrong');
Expand All @@ -143,7 +147,7 @@ describe('fetchWithMiddleware', () => {

expect.assertions(2);
try {
await fetchWithMiddleware(req, []);
await fetchWithMiddleware(req, [], []);
} catch (e) {
expect(e instanceof Error).toBeTruthy();
expect(e.toString()).toMatch('Server return empty response.data');
Expand Down
8 changes: 8 additions & 0 deletions src/definition.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import type RelayResponse from './RelayResponse';
export type RelayRequestAny = RelayRequest | RelayRequestBatch;
export type MiddlewareNextFn = (req: RelayRequestAny) => Promise<RelayResponse>;
export type Middleware = (next: MiddlewareNextFn) => MiddlewareNextFn;
export type MiddlewareRawNextFn = (req: RelayRequestAny) => Promise<FetchResponse>;

export type MiddlewareRaw = {
isRawMiddleware: true,
$call: (next: MiddlewareRawNextFn) => MiddlewareRawNextFn,
};

export type MiddlewareSync = {|
execute: (
Expand All @@ -30,6 +36,8 @@ export type FetchOpts = {
[name: string]: mixed,
};

export type FetchResponse = Response;

export type GraphQLResponseErrors = Array<{
message: string,
locations?: Array<{
Expand Down
34 changes: 26 additions & 8 deletions src/fetchWithMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@

import { createRequestError } from './createRequestError';
import RelayResponse from './RelayResponse';
import type { Middleware, MiddlewareNextFn, RelayRequestAny } from './definition';
import type {
Middleware,
MiddlewareNextFn,
RelayRequestAny,
MiddlewareRaw,
MiddlewareRawNextFn,
FetchResponse,
} from './definition';

async function runFetch(req: RelayRequestAny): Promise<RelayResponse> {
function runFetch(req: RelayRequestAny): Promise<FetchResponse> {
let { url } = req.fetchOpts;
if (!url) url = '/graphql';

Expand All @@ -14,21 +21,32 @@ async function runFetch(req: RelayRequestAny): Promise<RelayResponse> {
req.fetchOpts.headers['Content-Type'] = 'application/json';
}

// $FlowFixMe
const resFromFetch = await fetch(url, req.fetchOpts);
return fetch(url, (req.fetchOpts: any));
}

// convert fetch response to RelayResponse object
const convertResponse: (next: MiddlewareRawNextFn) => MiddlewareNextFn = next => async req => {
const resFromFetch = await next(req);

const res = await RelayResponse.createFromFetch(resFromFetch);
if (res.status && res.status >= 400) {
throw createRequestError(req, res);
}
return res;
}
};

export default function fetchWithMiddleware(
req: RelayRequestAny,
middlewares: Middleware[],
middlewares: Middleware[], // works with RelayResponse
rawFetchMiddlewares: MiddlewareRaw[], // works with raw fetch response
noThrow?: boolean
): Promise<RelayResponse> {
const wrappedFetch: MiddlewareNextFn = compose(...middlewares)(runFetch);
// $FlowFixMe
const wrappedFetch: MiddlewareNextFn = compose(
...middlewares,
convertResponse,
...rawFetchMiddlewares
)((runFetch: any));

return wrappedFetch(req).then(res => {
if (!noThrow && (!res || res.errors || !res.data)) {
Expand All @@ -54,6 +72,6 @@ function compose(...funcs) {
} else {
const last = funcs[funcs.length - 1];
const rest = funcs.slice(0, -1);
return (...args) => rest.reduceRight((composed, f) => f(composed), last(...args));
return (...args) => rest.reduceRight((composed, f) => f((composed: any)), last(...args));
}
}
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import perfMiddleware from './middlewares/perf';
import loggerMiddleware from './middlewares/logger';
import errorMiddleware from './middlewares/error';
import cacheMiddleware from './middlewares/cache';
import progressMiddleware from './middlewares/progress';
import graphqlBatchHTTPWrapper from './express-middleware/graphqlBatchHTTPWrapper';
import RelayNetworkLayerRequest from './RelayRequest';
import RelayNetworkLayerRequestBatch from './RelayRequestBatch';
Expand All @@ -27,5 +28,6 @@ export {
loggerMiddleware,
errorMiddleware,
cacheMiddleware,
progressMiddleware,
graphqlBatchHTTPWrapper,
};
Loading

0 comments on commit 525e256

Please sign in to comment.