From 928ef7d44154eed99c15362f106ab86ded3ebd1f Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Tue, 3 Jul 2018 11:41:43 +0200 Subject: [PATCH] Allow dynamic configuration of data sources (#1277) Closes #1181 and #1238. --- __mocks__/apollo-server-env.ts | 17 ++- .../src/RESTDataSource.ts | 110 +++++++++------- .../src/__tests__/RESTDataSource.test.ts | 117 ++++++++++++++++-- 3 files changed, 187 insertions(+), 57 deletions(-) diff --git a/__mocks__/apollo-server-env.ts b/__mocks__/apollo-server-env.ts index a6e4a15e..bb42272b 100644 --- a/__mocks__/apollo-server-env.ts +++ b/__mocks__/apollo-server-env.ts @@ -3,12 +3,15 @@ import { fetch, Request, + RequestInit, Response, + Body, BodyInit, Headers, HeadersInit, URL, URLSearchParams, + URLSearchParamsInit, } from '../packages/apollo-server-env'; interface FetchMock extends jest.Mock { @@ -43,4 +46,16 @@ mockFetch.mockJSONResponseOnce = ( ); }; -export { mockFetch as fetch, Request, Response, Headers, URL, URLSearchParams }; +export { + mockFetch as fetch, + Request, + RequestInit, + Response, + Body, + BodyInit, + Headers, + HeadersInit, + URL, + URLSearchParams, + URLSearchParamsInit, +}; diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts index 491f9f6c..80366884 100644 --- a/packages/apollo-datasource-rest/src/RESTDataSource.ts +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -1,9 +1,9 @@ import { - BodyInit, - Headers, Request, RequestInit, Response, + BodyInit, + Headers, URL, URLSearchParams, URLSearchParamsInit, @@ -17,19 +17,36 @@ import { } from 'apollo-server-errors'; export type RequestOptions = RequestInit & { - params?: URLSearchParamsInit; - body: Body; + path: string; + params: URLSearchParams; + headers: Headers; + body?: Body; }; + export type Body = BodyInit | object; export { Request }; -export abstract class RESTDataSource { - abstract baseURL: string; +type ValueOrPromise = T | Promise; +export abstract class RESTDataSource { httpCache!: HTTPCache; context!: TContext; - protected willSendRequest?(request: Request): void; + baseURL?: string; + + protected willSendRequest?(request: RequestOptions): ValueOrPromise; + + protected resolveURL(request: RequestOptions): ValueOrPromise { + const baseURL = this.baseURL; + if (baseURL) { + const normalizedBaseURL = baseURL.endsWith('/') + ? baseURL + : baseURL.concat('/'); + return new URL(request.path, normalizedBaseURL); + } else { + return new URL(request.path); + } + } protected async didReceiveErrorResponse( response: Response, @@ -50,96 +67,93 @@ export abstract class RESTDataSource { protected async get( path: string, params?: URLSearchParamsInit, - options?: RequestOptions, + init?: RequestInit, ): Promise { return this.fetch( - path, - Object.assign({ method: 'GET', params }, options), + Object.assign({ method: 'GET', path, params }, init), ); } protected async post( path: string, body?: Body, - options?: RequestOptions, + init?: RequestInit, ): Promise { return this.fetch( - path, - Object.assign({ method: 'POST', body }, options), + Object.assign({ method: 'POST', path, body }, init), ); } protected async patch( path: string, body?: Body, - options?: RequestOptions, + init?: RequestInit, ): Promise { return this.fetch( - path, - Object.assign({ method: 'PATCH', body }, options), + Object.assign({ method: 'PATCH', path, body }, init), ); } protected async put( path: string, body?: Body, - options?: RequestOptions, + init?: RequestInit, ): Promise { return this.fetch( - path, - Object.assign({ method: 'PUT', body }, options), + Object.assign({ method: 'PUT', path, body }, init), ); } protected async delete( path: string, params?: URLSearchParamsInit, - options?: RequestOptions, + init?: RequestInit, ): Promise { return this.fetch( - path, - Object.assign({ method: 'DELETE', params }, options), + Object.assign({ method: 'DELETE', path, params }, init), ); } private async fetch( - path: string, - options: RequestOptions, + init: RequestInit & { + path: string; + params?: URLSearchParamsInit; + }, ): Promise { - const { params, ...init } = options; + if (!(init.params instanceof URLSearchParams)) { + init.params = new URLSearchParams(init.params); + } - const normalizedBaseURL = this.baseURL.endsWith('/') - ? this.baseURL - : this.baseURL.concat('/'); - const url = new URL(path, normalizedBaseURL); + if (!(init.headers && init.headers instanceof Headers)) { + init.headers = new Headers(init.headers); + } - if (params) { - // Append params to existing params in the path - for (const [name, value] of new URLSearchParams(params)) { - url.searchParams.append(name, value); - } + const options = init as RequestOptions; + + if (this.willSendRequest) { + await this.willSendRequest(options); + } + + const url = await this.resolveURL(options); + + // Append params to existing params in the path + for (const [name, value] of options.params) { + url.searchParams.append(name, value); } // We accept arbitrary objects as body and serialize them as JSON if ( - init.body !== undefined && - typeof init.body !== 'string' && - !(init.body instanceof ArrayBuffer) + options.body !== undefined && + typeof options.body !== 'string' && + !(options.body instanceof ArrayBuffer) ) { - init.body = JSON.stringify(init.body); - if (!(init.headers instanceof Headers)) { - init.headers = new Headers(init.headers); - } - init.headers.set('Content-Type', 'application/json'); + options.body = JSON.stringify(options.body); + options.headers.set('Content-Type', 'application/json'); } - return this.trace(`${init.method || 'GET'} ${url}`, async () => { - const request = new Request(String(url), init); - - if (this.willSendRequest) { - this.willSendRequest(request); - } + const request = new Request(String(url), options); + return this.trace(`${options.method || 'GET'} ${url}`, async () => { const response = await this.httpCache.fetch(request); if (response.ok) { const contentType = response.headers.get('Content-Type'); diff --git a/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts b/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts index 615d1ed7..21264c90 100644 --- a/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts +++ b/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts @@ -5,7 +5,7 @@ import { AuthenticationError, ForbiddenError, } from 'apollo-server-errors'; -import { RESTDataSource, Request } from '../RESTDataSource'; +import { RESTDataSource, RequestOptions } from '../RESTDataSource'; import { HTTPCache } from '../HTTPCache'; @@ -86,7 +86,7 @@ describe('RESTDataSource', () => { expect(data).toEqual('bar'); }); - it('interprets paths relative to the baseURL', async () => { + it('interprets paths relative to the base URL', async () => { const dataSource = new class extends RESTDataSource { baseURL = 'https://api.example.com'; @@ -105,7 +105,7 @@ describe('RESTDataSource', () => { expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); }); - it('adds a trailing slash to the baseURL if needed', async () => { + it('adds a trailing slash to the base URL if needed', async () => { const dataSource = new class extends RESTDataSource { baseURL = 'https://example.com/api'; @@ -124,7 +124,57 @@ describe('RESTDataSource', () => { expect(fetch.mock.calls[0][0].url).toEqual('https://example.com/api/foo'); }); - it('allows adding query string parameters', async () => { + it('allows computing a dynamic base URL', async () => { + const dataSource = new class extends RESTDataSource { + get baseURL() { + if (this.context.env === 'development') { + return 'https://api-dev.example.com'; + } else { + return 'https://api.example.com'; + } + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.context = { env: 'development' }; + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual( + 'https://api-dev.example.com/foo', + ); + }); + + it('allows resolving a base URL asynchronously', async () => { + const dataSource = new class extends RESTDataSource { + async resolveURL(request: RequestOptions) { + if (!this.baseURL) { + this.baseURL = 'https://api.example.com'; + } + return super.resolveURL(request); + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); + }); + + it('allows passing in query string parameters', async () => { const dataSource = new class extends RESTDataSource { baseURL = 'https://api.example.com'; @@ -152,12 +202,38 @@ describe('RESTDataSource', () => { ); }); - it('allows setting request headers', async () => { + it('allows setting default query string parameters', async () => { const dataSource = new class extends RESTDataSource { baseURL = 'https://api.example.com'; - willSendRequest(request: Request) { - request.headers.set('Authorization', 'secret'); + willSendRequest(request: RequestOptions) { + request.params.set('api_key', this.context.token); + } + + getFoo() { + return this.get('foo', { a: 1 }); + } + }(); + + dataSource.context = { token: 'secret' }; + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual( + 'https://api.example.com/foo?a=1&api_key=secret', + ); + }); + + it('allows setting default fetch options', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + willSendRequest(request: RequestOptions) { + request.credentials = 'include'; } getFoo() { @@ -171,13 +247,38 @@ describe('RESTDataSource', () => { await dataSource.getFoo(); + expect(fetch.mock.calls.length).toEqual(1); + // FIXME: request.credentials is not supported by node-fetch + // expect(fetch.mock.calls[0][0].credentials).toEqual('include'); + }); + + it('allows setting request headers', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + willSendRequest(request: RequestOptions) { + request.headers.set('Authorization', this.context.token); + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.context = { token: 'secret' }; + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + expect(fetch.mock.calls.length).toEqual(1); expect(fetch.mock.calls[0][0].headers.get('Authorization')).toEqual( 'secret', ); }); - it('allows passing a request body', async () => { + it('allows passing in a request body', async () => { const dataSource = new class extends RESTDataSource { baseURL = 'https://api.example.com';