Allow dynamic configuration of data sources (#1277)

Closes #1181 and #1238.
This commit is contained in:
Martijn Walraven 2018-07-03 11:41:43 +02:00 committed by GitHub
parent 16a336d3e5
commit 928ef7d441
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 187 additions and 57 deletions

View file

@ -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<typeof fetch> {
@ -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,
};

View file

@ -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<TContext = any> {
abstract baseURL: string;
type ValueOrPromise<T> = T | Promise<T>;
export abstract class RESTDataSource<TContext = any> {
httpCache!: HTTPCache;
context!: TContext;
protected willSendRequest?(request: Request): void;
baseURL?: string;
protected willSendRequest?(request: RequestOptions): ValueOrPromise<void>;
protected resolveURL(request: RequestOptions): ValueOrPromise<URL> {
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<TResult = any>(
response: Response,
@ -50,96 +67,93 @@ export abstract class RESTDataSource<TContext = any> {
protected async get<TResult = any>(
path: string,
params?: URLSearchParamsInit,
options?: RequestOptions,
init?: RequestInit,
): Promise<TResult> {
return this.fetch<TResult>(
path,
Object.assign({ method: 'GET', params }, options),
Object.assign({ method: 'GET', path, params }, init),
);
}
protected async post<TResult = any>(
path: string,
body?: Body,
options?: RequestOptions,
init?: RequestInit,
): Promise<TResult> {
return this.fetch<TResult>(
path,
Object.assign({ method: 'POST', body }, options),
Object.assign({ method: 'POST', path, body }, init),
);
}
protected async patch<TResult = any>(
path: string,
body?: Body,
options?: RequestOptions,
init?: RequestInit,
): Promise<TResult> {
return this.fetch<TResult>(
path,
Object.assign({ method: 'PATCH', body }, options),
Object.assign({ method: 'PATCH', path, body }, init),
);
}
protected async put<TResult = any>(
path: string,
body?: Body,
options?: RequestOptions,
init?: RequestInit,
): Promise<TResult> {
return this.fetch<TResult>(
path,
Object.assign({ method: 'PUT', body }, options),
Object.assign({ method: 'PUT', path, body }, init),
);
}
protected async delete<TResult = any>(
path: string,
params?: URLSearchParamsInit,
options?: RequestOptions,
init?: RequestInit,
): Promise<TResult> {
return this.fetch<TResult>(
path,
Object.assign({ method: 'DELETE', params }, options),
Object.assign({ method: 'DELETE', path, params }, init),
);
}
private async fetch<TResult>(
path: string,
options: RequestOptions,
init: RequestInit & {
path: string;
params?: URLSearchParamsInit;
},
): Promise<TResult> {
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');

View file

@ -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';