From d33e696f8245078538520cfe4576544350731693 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 19 Mar 2016 00:33:55 -0400 Subject: [PATCH 1/3] started working on network layer following pattern of Relay --- package.json | 3 + src/networkLayer.js | 102 ++++++++++++++++++++++++ test/networkLayer.js | 183 +++++++++++++++++++++++++++++++++++++++++++ test/tests.js | 1 + 4 files changed, 289 insertions(+) create mode 100644 src/networkLayer.js create mode 100644 test/networkLayer.js diff --git a/package.json b/package.json index d9ac7b2ed75..9c3361dc62a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "license": "MIT", "dependencies": { "babel-polyfill": "^6.5.0", + "es6-promise": "^3.1.2", "graphql": "^0.4.17", + "isomorphic-fetch": "^2.2.1", "lodash": "^4.6.1", "redux": "^3.3.1" }, @@ -39,6 +41,7 @@ "babel-preset-es2015": "^6.5.0", "babel-preset-stage-0": "^6.5.0", "chai": "^3.5.0", + "chai-as-promised": "^5.2.0", "dataloader": "^1.1.0", "eslint": "^2.4.0", "eslint-config-airbnb": "^6.1.0", diff --git a/src/networkLayer.js b/src/networkLayer.js new file mode 100644 index 00000000000..b08e743628a --- /dev/null +++ b/src/networkLayer.js @@ -0,0 +1,102 @@ +// ensure env has promise support +import { polyfill } from 'es6-promise'; +polyfill(); + +import fetch from 'isomorphic-fetch'; + +import { + isString, + isArray, +} from 'lodash'; + +class NetworkLayer { + + constructor(uri, opts = {}) { + if (!uri) { + throw new Error('A remote enpdoint is required for a newtork layer'); + } + + if (!isString(uri)) { + throw new Error('Uri must be a string'); + } + + this._uri = uri; + this._opts = { ...opts }; + } + + query(requests) { + let clonedRequests = []; + + if (!isArray(requests)) { + clonedRequests = [requests]; + } else { + clonedRequests = [...requests]; + } + + return Promise.all(clonedRequests.map(request => ( + this._query(request).then( + result => result.json() + ).then(payload => { + if (payload.hasOwnProperty('errors')) { + const error = new Error( + `Server request for query '${request.getDebugName()}' + failed for the following reasons:\n\n + ${formatRequestErrors(request, payload.errors)}` + ); + error.source = payload; + throw error; + } else if (!payload.hasOwnProperty('data')) { + throw new Error( + `Server response was missing for query '${request.getDebugName()}'.` + ); + } else { + return payload; + } + }) + ))); + } + + _query(request) { + return fetch(this._uri, { + ...this._opts, + body: JSON.stringify({ + query: request.getQueryString(), + variables: request.getVariables(), + }), + headers: { + ...this._opts.headers, + Accept: '*/*', + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + } + +} + +function formatRequestErrors(request, errors) { + const CONTEXT_BEFORE = 20; + const CONTEXT_LENGTH = 60; + + const queryLines = request.getQueryString().split('\n'); + return errors.map(({ locations, message }, ii) => { + const prefix = `${(ii + 1)}. `; + const indent = ' '.repeat(prefix.length); + + // custom errors thrown in graphql-server may not have locations + const locationMessage = locations ? + (`\n${locations.map(({ column, line }) => { + const queryLine = queryLines[line - 1]; + const offset = Math.min(column - 1, CONTEXT_BEFORE); + return [ + queryLine.substr(column - 1 - offset, CONTEXT_LENGTH), + `${' '.repeat(offset)}^^^`, + ].map(messageLine => indent + messageLine).join('\n'); + }).join('\n')}`) : + ''; + + return prefix + message + locationMessage; + }).join('\n'); +} + +export default NetworkLayer; diff --git a/test/networkLayer.js b/test/networkLayer.js new file mode 100644 index 00000000000..2eabe9fae91 --- /dev/null +++ b/test/networkLayer.js @@ -0,0 +1,183 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +// make it easy to assert with promises +chai.use(chaiAsPromised); + +const { assert } = chai; + +import NetworkLayer from '../src/networkLayer'; + +describe('NetworkLayer', () => { + describe('constructor()', () => { + it('should throw without an endpoint', () => { + assert.throws(() => { + const networkLayer = new NetworkLayer(); // eslint-disable-line no-unused-vars + }, /A remote enpdoint is required for a newtork layer/); + }); + + it('should create an instance with a given uri', () => { + const networkLayer = new NetworkLayer('/graphql'); + assert.equal(networkLayer._uri, '/graphql'); + }); + + it('should require uri to be a string', () => { + assert.throws(() => { + const networkLayer = new NetworkLayer(true); // eslint-disable-line no-unused-vars + }, /Uri must be a string/); + }); + + it('should allow for storing of custom options', () => { + const customOpts = { + headers: { TestHeader: 'working' }, + credentials: 'include', + }; + + const networkLayer = new NetworkLayer('/graphql', { ...customOpts }); + + assert.deepEqual(networkLayer._opts, { ...customOpts }); + }); + + it('should not mutate custom options', () => { + const customOpts = { + headers: { TestHeader: 'working' }, + credentials: 'include', + }; + const originalOpts = { ...customOpts }; + + const networkLayer = new NetworkLayer('/graphql', customOpts); + + delete customOpts.headers; + + assert.deepEqual(networkLayer._opts, originalOpts); + }); + }); + + describe('making a request', () => { + it('should fetch remote data', (done) => { + const Swapi = new NetworkLayer('http://graphql-swapi.parseapp.com/'); + + // this is a stub for the end user client api + const simpleRequest = { + getQueryString() { + return ` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + }, + getVariables() { return {}; }, + getDebugName() { return 'People query'; }, + }; + + assert.eventually.deepEqual( + Swapi.query(simpleRequest), + [ + { + data: { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }, + }, + ] + ).notify(done); + }); + + it('should throw an error if the request fails', (done) => { + const Swapi = new NetworkLayer('http://graphql-swapi.parseapp.com/'); + + // this is a stub for the end user client api + const simpleRequest = { + getQueryString() { + return ` + query people { + allPeople(first: 1) { + people { + name + } + } + `; + }, + getVariables() { return {}; }, + getDebugName() { return 'People query'; }, + }; + + assert.isRejected(Swapi.query(simpleRequest), /Server request for query/) + .notify(done); + }); + + it('should allow for multiple requests at once', (done) => { + const Swapi = new NetworkLayer('http://graphql-swapi.parseapp.com/'); + + // this is a stub for the end user client api + const firstRequest = { + getQueryString() { + return ` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + }, + getVariables() { return {}; }, + getDebugName() { return 'People query'; }, + }; + + const secondRequest = { + getQueryString() { + return ` + query ships { + allStarships(first: 1) { + starships { + name + } + } + } + `; + }, + getVariables() { return {}; }, + getDebugName() { return 'Ships query'; }, + }; + + assert.eventually.deepEqual( + Swapi.query([firstRequest, secondRequest]), + [ + { + data: { + allPeople: { + people: [ + { + name: 'Luke Skywalker', + }, + ], + }, + }, + }, + { + data: { + allStarships: { + starships: [ + { + name: 'CR90 corvette', + }, + ], + }, + }, + }, + ] + ).notify(done); + }); + }); +}); diff --git a/test/tests.js b/test/tests.js index e8563023c18..7564314347b 100644 --- a/test/tests.js +++ b/test/tests.js @@ -1,3 +1,4 @@ import './writeToStore'; import './readFromStore'; import './roundtrip'; +import './networkLayer'; From 191d1a53004837e49e5ab774a622cdfcb70bc7c3 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 19 Mar 2016 01:48:21 -0400 Subject: [PATCH 2/3] add attribution to relay --- src/networkLayer.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/networkLayer.js b/src/networkLayer.js index b08e743628a..de544536663 100644 --- a/src/networkLayer.js +++ b/src/networkLayer.js @@ -74,6 +74,12 @@ class NetworkLayer { } +/* + + An easy way to breakdown the errors of a query + from https://github.com/facebook/relay/blob/master/src/network-layer/default/RelayDefaultNetworkLayer.js#L174 + +*/ function formatRequestErrors(request, errors) { const CONTEXT_BEFORE = 20; const CONTEXT_LENGTH = 60; From c72ce50afaf878d47e8f198068b72a36d12c57ab Mon Sep 17 00:00:00 2001 From: James Baxley Date: Sat, 19 Mar 2016 02:04:37 -0400 Subject: [PATCH 3/3] fix spelling of endpoint and remove multi type arugment support in favor of array only --- src/networkLayer.js | 10 ++++------ test/networkLayer.js | 6 +++--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/networkLayer.js b/src/networkLayer.js index de544536663..d747365cb5c 100644 --- a/src/networkLayer.js +++ b/src/networkLayer.js @@ -13,7 +13,7 @@ class NetworkLayer { constructor(uri, opts = {}) { if (!uri) { - throw new Error('A remote enpdoint is required for a newtork layer'); + throw new Error('A remote endpoint is required for a newtork layer'); } if (!isString(uri)) { @@ -25,14 +25,12 @@ class NetworkLayer { } query(requests) { - let clonedRequests = []; - if (!isArray(requests)) { - clonedRequests = [requests]; - } else { - clonedRequests = [...requests]; + throw new Error('Requests must be an array of requests'); } + const clonedRequests = [...requests]; + return Promise.all(clonedRequests.map(request => ( this._query(request).then( result => result.json() diff --git a/test/networkLayer.js b/test/networkLayer.js index 2eabe9fae91..481526c22e5 100644 --- a/test/networkLayer.js +++ b/test/networkLayer.js @@ -13,7 +13,7 @@ describe('NetworkLayer', () => { it('should throw without an endpoint', () => { assert.throws(() => { const networkLayer = new NetworkLayer(); // eslint-disable-line no-unused-vars - }, /A remote enpdoint is required for a newtork layer/); + }, /A remote endpoint is required for a newtork layer/); }); it('should create an instance with a given uri', () => { @@ -75,7 +75,7 @@ describe('NetworkLayer', () => { }; assert.eventually.deepEqual( - Swapi.query(simpleRequest), + Swapi.query([simpleRequest]), [ { data: { @@ -111,7 +111,7 @@ describe('NetworkLayer', () => { getDebugName() { return 'People query'; }, }; - assert.isRejected(Swapi.query(simpleRequest), /Server request for query/) + assert.isRejected(Swapi.query([simpleRequest]), /Server request for query/) .notify(done); });