diff --git a/CHANGELOG.md b/CHANGELOG.md index 354ceaecf72..f8007634d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Expect active development and potentially significant breaking changes in the `0 - Fixed issue with error passing with query merging. [PR #589](https://github.com/apollostack/apollo-client/pull/589) and [Issue #551](https://github.com/apollostack/apollo-client/issues/551). - [Experimental] Change subscription API to `subscribe` function on Apollo Client instance, and remove `fetchMore`-style API temporarily. - Fixed an issue with batching and variables used in directives. [PR #584](https://github.com/apollostack/apollo-client/pull/584) and [Issue #577](https://github.com/apollostack/apollo-client/issues/577). +- Implemented transport-level batching the way it is currently supported within Apollo Server. [PR #531](https://github.com/apollostack/apollo-client/pull/531) and [Issue #505](https://github.com/apollostack/apollo-client/issues/505). ### v0.4.13 diff --git a/src/batchedNetworkInterface.ts b/src/batchedNetworkInterface.ts new file mode 100644 index 00000000000..438614d6b7b --- /dev/null +++ b/src/batchedNetworkInterface.ts @@ -0,0 +1,95 @@ +import { + HTTPFetchNetworkInterface, + RequestAndOptions, + Request, + printRequest, + BatchedNetworkInterface, +} from './networkInterface'; + +import { + GraphQLResult, +} from 'graphql'; + +import 'whatwg-fetch'; + +import assign = require('lodash.assign'); + +// An implementation of the network interface that operates over HTTP and batches +// together requests over the HTTP transport. Note that this implementation will only work correctly +// for GraphQL server implementations that support batching. If such a server is not available, you +// should see `addQueryMerging` instead. +export class HTTPBatchedNetworkInterface extends HTTPFetchNetworkInterface implements BatchedNetworkInterface { + constructor(uri: string, opts: RequestInit) { + super(uri, opts); + }; + + public batchedFetchFromRemoteEndpoint( + requestsAndOptions: RequestAndOptions[] + ): Promise { + const options: RequestInit = {}; + + // Combine all of the options given by the middleware into one object. + requestsAndOptions.forEach((requestAndOptions) => { + assign(options, requestAndOptions.options); + }); + + // Serialize the requests to strings of JSON + const printedRequests = requestsAndOptions.map(({ request }) => { + return printRequest(request); + }); + + return fetch(this._uri, assign({}, this._opts, options, { + body: JSON.stringify(printedRequests), + headers: assign({}, options.headers, { + Accept: '*/*', + 'Content-Type': 'application/json', + }), + method: 'POST', + })); + }; + + public batchQuery(requests: Request[]): Promise { + const options = assign({}, this._opts); + + // Apply the middlewares to each of the requests + const middlewarePromises: Promise[] = []; + requests.forEach((request) => { + middlewarePromises.push(this.applyMiddlewares({ + request, + options, + })); + }); + + return new Promise((resolve, reject) => { + Promise.all(middlewarePromises).then((requestsAndOptions: RequestAndOptions[]) => { + return this.batchedFetchFromRemoteEndpoint(requestsAndOptions) + .then(result => { + return result.json(); + }) + .then(responses => { + const afterwaresPromises = responses.map((response: IResponse, index: number) => { + return this.applyAfterwares({ + response, + options: requestsAndOptions[index].options, + }); + }); + + Promise.all(afterwaresPromises).then((responsesAndOptions: { + response: IResponse, + options: RequestInit, + }[]) => { + const results: Array = []; + responsesAndOptions.forEach(({ response }) => { + results.push(response); + }); + resolve(results); + }).catch((error) => { + reject(error); + }); + }); + }).catch((error) => { + reject(error); + }); + }); + } +} diff --git a/src/networkInterface.ts b/src/networkInterface.ts index caba7867b5e..cfd919633fd 100644 --- a/src/networkInterface.ts +++ b/src/networkInterface.ts @@ -98,21 +98,28 @@ export function printRequest(request: Request): PrintedRequest { return printedRequest; } -export function createNetworkInterface(uri: string, opts: RequestInit = {}): HTTPNetworkInterface { - if (!uri) { - throw new Error('A remote enpdoint is required for a network layer'); - } +export class HTTPFetchNetworkInterface implements NetworkInterface { + public _uri: string; + public _opts: RequestInit; + public _middlewares: MiddlewareInterface[]; + public _afterwares: AfterwareInterface[]; - if (!isString(uri)) { - throw new Error('Remote endpoint must be a string'); - } + constructor(uri: string, opts: RequestInit = {}) { + if (!uri) { + throw new Error('A remote enpdoint is required for a network layer'); + } + + if (!isString(uri)) { + throw new Error('Remote endpoint must be a string'); + } - const _uri: string = uri; - const _opts: RequestInit = assign({}, opts); - const _middlewares: MiddlewareInterface[] = []; - const _afterwares: AfterwareInterface[] = []; + this._uri = uri; + this._opts = assign({}, opts); + this._middlewares = []; + this._afterwares = []; + } - function applyMiddlewares({ + public applyMiddlewares({ request, options, }: RequestAndOptions): Promise { @@ -133,11 +140,11 @@ export function createNetworkInterface(uri: string, opts: RequestInit = {}): HTT }; // iterate through middlewares using next callback - queue([..._middlewares], this); + queue([...this._middlewares], this); }); } - function applyAfterwares({ + public applyAfterwares({ response, options, }: ResponseAndOptions): Promise { @@ -158,15 +165,15 @@ export function createNetworkInterface(uri: string, opts: RequestInit = {}): HTT }; // iterate through afterwares using next callback - queue([..._afterwares], this); + queue([...this._afterwares], this); }); } - function fetchFromRemoteEndpoint({ + public fetchFromRemoteEndpoint({ request, options, }: RequestAndOptions): Promise { - return fetch(uri, assign({}, _opts, options, { + return fetch(this._uri, assign({}, this._opts, options, { body: JSON.stringify(printRequest(request)), headers: assign({}, options.headers, { Accept: '*/*', @@ -176,21 +183,21 @@ export function createNetworkInterface(uri: string, opts: RequestInit = {}): HTT })); }; - function query(request: Request): Promise { - const options = assign({}, _opts); + public query(request: Request): Promise { + const options = assign({}, this._opts); - return applyMiddlewares({ + return this.applyMiddlewares({ request, options, - }).then(fetchFromRemoteEndpoint) + }).then(this.fetchFromRemoteEndpoint.bind(this)) .then(response => { - applyAfterwares({ - response, + this.applyAfterwares({ + response: response as IResponse, options, }); return response; }) - .then(result => result.json()) + .then(result => (result as IResponse).json()) .then((payload: GraphQLResult) => { if (!payload.hasOwnProperty('data') && !payload.hasOwnProperty('errors')) { throw new Error( @@ -202,35 +209,67 @@ export function createNetworkInterface(uri: string, opts: RequestInit = {}): HTT }); }; - function use(middlewares: MiddlewareInterface[]) { + public use(middlewares: MiddlewareInterface[]) { middlewares.map((middleware) => { if (typeof middleware.applyMiddleware === 'function') { - _middlewares.push(middleware); + this._middlewares.push(middleware); } else { throw new Error('Middleware must implement the applyMiddleware function'); } }); } - function useAfter(afterwares: AfterwareInterface[]) { + public useAfter(afterwares: AfterwareInterface[]) { afterwares.map(afterware => { if (typeof afterware.applyAfterware === 'function') { - _afterwares.push(afterware); + this._afterwares.push(afterware); } else { throw new Error('Afterware must implement the applyAfterware function'); } }); } +} - // createNetworkInterface has batching ability by default, which is not used unless the - // `shouldBatch` option is passed to apollo-client - return addQueryMerging({ - _uri, - _opts, - _middlewares, - _afterwares, - query, - use, - useAfter, - }) as HTTPNetworkInterface; +// This import has to be placed here due to a bug in TypeScript: +// https://github.com/Microsoft/TypeScript/issues/21 +import { + HTTPBatchedNetworkInterface, +} from './batchedNetworkInterface'; + + +export interface NetworkInterfaceOptions { + uri: string; + opts?: RequestInit; + transportBatching?: boolean; +} + +// This function is written to preserve backwards compatibility. +// Specifically, there are two ways of calling `createNetworkInterface`: +// 1. createNetworkInterface(uri: string, opts: RequestInit) +// 2. createnetworkInterface({ uri: string, opts: RequestInit, transportBatching: boolean}) +// where the latter is preferred over the former. +// +// XXX remove this backward compatibility feature by 1.0 +export function createNetworkInterface( + interfaceOpts: (NetworkInterfaceOptions | string), + backOpts: RequestInit = {} +): HTTPNetworkInterface { + if (isString(interfaceOpts) || !interfaceOpts) { + const uri = interfaceOpts as string; + return addQueryMerging(new HTTPFetchNetworkInterface(uri, backOpts)) as HTTPNetworkInterface; + } else { + const { + transportBatching = false, + opts = {}, + uri, + } = interfaceOpts as NetworkInterfaceOptions; + if (transportBatching) { + // We can use transport batching rather than query merging. + return new HTTPBatchedNetworkInterface(uri, opts) as HTTPNetworkInterface; + } else { + // createNetworkInterface has batching ability by default through query merging, + // which is not used unless the `shouldBatch` option is passed to apollo-client. + return addQueryMerging(new HTTPFetchNetworkInterface(uri, opts)) as HTTPNetworkInterface; + } + } } diff --git a/test/batchedNetworkInterface.ts b/test/batchedNetworkInterface.ts new file mode 100644 index 00000000000..d92ba070096 --- /dev/null +++ b/test/batchedNetworkInterface.ts @@ -0,0 +1,226 @@ +import { assert } from 'chai'; + +import merge = require('lodash.merge'); + +import { HTTPBatchedNetworkInterface } from '../src/batchedNetworkInterface'; + +import { + createMockFetch, + createMockedIResponse, +} from './mocks/mockFetch'; + +import { + Request, + printRequest, +} from '../src/networkInterface'; + +import { MiddlewareInterface } from '../src/middleware'; +import { AfterwareInterface } from '../src/afterware'; + +import { GraphQLResult } from 'graphql'; + +import 'whatwg-fetch'; + +import gql from 'graphql-tag'; + +describe('HTTPBatchedNetworkInterface', () => { + // Helper method that tests a roundtrip given a particular set of requests to the + // batched network interface and the + const assertRoundtrip = ({ + requestResultPairs, + fetchFunc, + middlewares = [], + afterwares = [], + opts = {}, + }: { + requestResultPairs: { + request: Request, + result: GraphQLResult, + }[]; + fetchFunc?: any; + middlewares?: MiddlewareInterface[]; + afterwares?: AfterwareInterface[]; + opts?: RequestInit, + }) => { + const url = 'http://fake.com/graphql'; + const batchedNetworkInterface = new HTTPBatchedNetworkInterface(url, opts); + + batchedNetworkInterface.use(middlewares); + batchedNetworkInterface.useAfter(afterwares); + + const printedRequests: Array = []; + const resultList: Array = []; + requestResultPairs.forEach(({ request, result }) => { + printedRequests.push(printRequest(request)); + resultList.push(result); + }); + + fetch = fetchFunc || createMockFetch({ + url, + opts: merge(opts, { + body: JSON.stringify(printedRequests), + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + method: 'POST', + }), + result: createMockedIResponse(resultList), + }); + + return batchedNetworkInterface.batchQuery(requestResultPairs.map(({ request }) => request)) + .then((results) => { + assert.deepEqual(results, resultList); + }); + }; + + // Some helper queries + results + const authorQuery = gql` + query { + author { + firstName + lastName + } + }`; + + const authorResult = { + data: { + author: { + firstName: 'John', + lastName: 'Smith', + }, + }, + }; + + const personQuery = gql` + query { + person { + name + } + }`; + const personResult = { + data: { + person: { + name: 'John Smith', + }, + }, + }; + + it('should construct itself correctly', () => { + const url = 'http://notreal.com/graphql'; + const opts = {}; + const batchedNetworkInterface = new HTTPBatchedNetworkInterface(url, opts); + assert(batchedNetworkInterface); + assert.equal(batchedNetworkInterface._uri, url); + assert.deepEqual(batchedNetworkInterface._opts, opts); + assert(batchedNetworkInterface.batchQuery); + }); + + it('should correctly return the result for a single request', () => { + return assertRoundtrip({ + requestResultPairs: [{ + request: { query: authorQuery }, + result: authorResult, + }], + }); + }); + + it('should correctly return the results for multiple requests', () => { + return assertRoundtrip({ + requestResultPairs: [ + { + request: { query: authorQuery }, + result: authorResult, + }, + { + request: { query: personQuery }, + result: personResult, + }, + ], + }); + }); + + describe('errors', () => { + it('should return errors thrown by fetch', (done) => { + const err = new Error('Error of some kind thrown by fetch.'); + const fetchFunc = () => { throw err; }; + assertRoundtrip({ + requestResultPairs: [{ + request: { query: authorQuery }, + result: authorResult, + }], + fetchFunc, + }).then(() => { + done(new Error('Assertion passed when it should not have.')); + }).catch((error) => { + assert(error); + assert.deepEqual(error, err); + done(); + }); + }); + + it('should return errors thrown by middleware', (done) => { + const err = new Error('Error of some kind thrown by middleware.'); + const errorMiddleware: MiddlewareInterface = { + applyMiddleware() { + throw err; + }, + }; + assertRoundtrip({ + requestResultPairs: [{ + request: { query: authorQuery }, + result: authorResult, + }], + middlewares: [ errorMiddleware ], + }).then(() => { + done(new Error('Returned a result when it should not have.')); + }).catch((error) => { + assert.deepEqual(error, err); + done(); + }); + }); + + it('should return errors thrown by afterware', (done) => { + const err = new Error('Error of some kind thrown by afterware.'); + const errorAfterware: AfterwareInterface = { + applyAfterware() { + throw err; + }, + }; + assertRoundtrip({ + requestResultPairs: [{ + request: { query: authorQuery }, + result: authorResult, + }], + afterwares: [ errorAfterware ], + }).then(() => { + done(new Error('Returned a result when it should not have.')); + }).catch((error) => { + assert.deepEqual(error, err); + done(); + }); + }); + }); + + it('middleware should be able to modify requests/options', () => { + const changeMiddleware: MiddlewareInterface = { + applyMiddleware({ options }, next) { + (options as any).headers['Content-Length'] = '18'; + next(); + }, + }; + + const customHeaders: { [index: string]: string } = { + 'Content-Length': '18', + }; + const options = { headers: customHeaders }; + return assertRoundtrip({ + requestResultPairs: [{ + request: { query: authorQuery }, + result: authorResult, + }], + opts: options, + middlewares: [ changeMiddleware ], + }); + }); +}); diff --git a/test/client.ts b/test/client.ts index 218b12e4f37..7a47e12bbd1 100644 --- a/test/client.ts +++ b/test/client.ts @@ -64,6 +64,11 @@ import * as chaiAsPromised from 'chai-as-promised'; import { ApolloError } from '../src/errors'; +import { + createMockFetch, + createMockedIResponse, +} from './mocks/mockFetch'; + // make it easy to assert with promises chai.use(chaiAsPromised); @@ -1604,4 +1609,73 @@ describe('client', () => { } as QueryManager; client.resetStore(); }); + it('should allow us to create a network interface with transport-level batching', (done) => { + const firstQuery = gql` + query { + author { + firstName + lastName + } + }`; + const firstResult = { + data: { + author: { + firstName: 'John', + lastName: 'Smith', + }, + }, + loading: false, + }; + const secondQuery = gql` + query { + person { + name + } + }`; + const secondResult = { + data: { + person: { + name: 'Jane Smith', + }, + }, + }; + const url = 'http://not-a-real-url.com'; + const oldFetch = fetch; + fetch = createMockFetch({ + url, + opts: { + body: JSON.stringify([ + { + query: print(firstQuery), + }, + { + query: print(secondQuery), + }, + ]), + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + method: 'POST', + }, + result: createMockedIResponse([firstResult, secondResult]), + }); + const networkInterface = createNetworkInterface({ + uri: 'http://not-a-real-url.com', + opts: {}, + transportBatching: true, + }); + networkInterface.batchQuery([ + { + query: firstQuery, + }, + { + query: secondQuery, + }, + ]).then((results) => { + assert.deepEqual(results, [firstResult, secondResult]); + fetch = oldFetch; + done(); + }); + }); }); diff --git a/test/mocks/mockFetch.ts b/test/mocks/mockFetch.ts new file mode 100644 index 00000000000..4678c93d5de --- /dev/null +++ b/test/mocks/mockFetch.ts @@ -0,0 +1,86 @@ +import 'whatwg-fetch'; + +// This is an implementation of a mocked window.fetch implementation similar in +// structure to the MockedNetworkInterface. + +export interface MockedIResponse { + json(): Promise; +} + +export interface MockedFetchResponse { + url: string; + opts: RequestInit; + result: MockedIResponse; + delay?: number; +} + +export function createMockedIResponse(result: Object): MockedIResponse { + return { + json() { + return Promise.resolve(result); + }, + }; +} + +export class MockFetch { + private mockedResponsesByKey: { [key: string]: MockedFetchResponse[] }; + + constructor(...mockedResponses: MockedFetchResponse[]) { + this.mockedResponsesByKey = {}; + + mockedResponses.forEach((mockedResponse) => { + this.addMockedResponse(mockedResponse); + }); + } + + public addMockedResponse(mockedResponse: MockedFetchResponse) { + const key = this.fetchParamsToKey(mockedResponse.url, mockedResponse.opts); + let mockedResponses = this.mockedResponsesByKey[key]; + + if (!mockedResponses) { + mockedResponses = []; + this.mockedResponsesByKey[key] = mockedResponses; + } + + mockedResponses.push(mockedResponse); + } + + public fetch(url: string, opts: RequestInit) { + const key = this.fetchParamsToKey(url, opts); + const responses = this.mockedResponsesByKey[key]; + + if (!responses || responses.length === 0) { + throw new Error(`No more mocked fetch responses for the params ${url} and ${opts}`); + } + + const { result, delay } = responses.shift(); + + if (!result) { + throw new Error(`Mocked fetch response should contain a result.`); + } + + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(result); + }, delay ? delay : 0); + }); + } + + public fetchParamsToKey(url: string, opts: RequestInit): string { + return JSON.stringify({ + url, + opts, + }); + } + + // Returns a "fetch" function equivalent that mocks the given responses. + // The function by returned by this should be tacked onto the global scope + // inorder to test functions that use "fetch". + public getFetch() { + return this.fetch.bind(this); + } +} + +export function createMockFetch(...mockedResponses: MockedFetchResponse[]) { + return new MockFetch(...mockedResponses).getFetch(); +} diff --git a/test/tests.ts b/test/tests.ts index b9a571fe27a..37f7b0ec46a 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -29,3 +29,4 @@ import './scopeQuery'; import './errors'; import './mockNetworkInterface'; import './graphqlSubscriptions'; +import './batchedNetworkInterface';