mirror of
https://github.com/vale981/apollo-server
synced 2025-03-04 17:21:42 -05:00
Apollo Server 2.0 - Caching + RESTDataSource (#1163)
* Enable declarationMap in tsconfig.json See http://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-9.html * Add apollo-server-caching package and improve typings * Remove superfluous test steps * Add .npmignore to apollo-server-caching * Add apollo-server-env and apollo-datasource-rest packages * Fix broken imports * Use prepublish instead of prepare * cache is now passed to data sources from ApolloServer constructor * fix Object.values to use the object passed in rather than this * add initial datasource test * docs: initial data source documentation * docs: initial data source documentation * compiles and documentation now highlights code in data-sources.md * Some changes to the data source docs
This commit is contained in:
parent
439ec44135
commit
685d3399db
36 changed files with 1334 additions and 5 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -5,6 +5,7 @@
|
|||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.wordWrapColumn": 110,
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.singleQuote": true,
|
||||
"prettier.printWidth": 110,
|
||||
"files.exclude": {
|
||||
|
|
|
@ -17,6 +17,7 @@ sidebar_categories:
|
|||
- features/mocking
|
||||
- features/errors
|
||||
- features/apq
|
||||
- features/data-sources
|
||||
- features/logging
|
||||
- features/scalars-enums
|
||||
- features/unions-interfaces
|
||||
|
|
72
docs/source/features/data-sources.md
Normal file
72
docs/source/features/data-sources.md
Normal file
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: Data Sources
|
||||
description: Caching Partial Query Results
|
||||
---
|
||||
|
||||
Data sources are components that encapsulate loading data from a particular backend, like a REST API, with built in best practices for caching, deduplication, batching, and error handling. You write the code that is specific to your backend, and Apollo Server takes care of the rest.
|
||||
|
||||
## REST Data Source
|
||||
|
||||
A `RESTDataSource` is responsible for fetching data from a given REST API. It contains data source specific configuration and relies on convenience methods to perform HTTP requests.
|
||||
|
||||
You define a data source by extending the `RESTDataSource` class. This code snippet shows a data source for the Star Wars API. Note that these requests will be automatically cached based on the caching headers returned from the API.
|
||||
|
||||
```js
|
||||
export class StarWarsAPI extends RESTDataSource {
|
||||
baseURL = 'https://swapi.co/api/';
|
||||
|
||||
async getPerson(id: string) {
|
||||
return this.get(`people/${id}`);
|
||||
}
|
||||
|
||||
async searchPerson(search: string) {
|
||||
return this.get(`people/`, {
|
||||
search,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To create a data source, we provide them to the `ApolloServer` constructor
|
||||
|
||||
```js
|
||||
const server = new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
dataSources: () => ({
|
||||
starWars: new StarWarsAPI(),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Apollo Server will put the data sources on the context, so you can access them from your resolvers. It will also give data sources access to the context, which is why they need to be configured separately.
|
||||
|
||||
Then in our resolvers, we can access the data source and return the result:
|
||||
|
||||
```js
|
||||
const typeDefs = gql`
|
||||
type Query {
|
||||
person: Person
|
||||
}
|
||||
|
||||
type Person {
|
||||
name: String
|
||||
}
|
||||
`;
|
||||
|
||||
const resolvers = {
|
||||
Query: {
|
||||
person: (_, id, { dataSources }) => {
|
||||
return dataSources.starWars.getPerson(id);
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The raw response from the Star Wars REST API appears as follows:
|
||||
|
||||
```js
|
||||
{
|
||||
"name": "Luke Skywalker",
|
||||
}
|
||||
```
|
|
@ -13,7 +13,5 @@
|
|||
}
|
||||
},
|
||||
"hoist": true,
|
||||
"packages": [
|
||||
"packages/*"
|
||||
]
|
||||
"packages": ["packages/*"]
|
||||
}
|
||||
|
|
6
packages/apollo-datasource-rest/.npmignore
Normal file
6
packages/apollo-datasource-rest/.npmignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
*
|
||||
!src/**/*
|
||||
!dist/**/*
|
||||
dist/**/*.test.*
|
||||
!package.json
|
||||
!README.md
|
53
packages/apollo-datasource-rest/package.json
Normal file
53
packages/apollo-datasource-rest/package.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "apollo-datasource-rest",
|
||||
"version": "2.0.0-beta.7",
|
||||
"author": "opensource@apollographql.com",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-datasource-rest"
|
||||
},
|
||||
"homepage": "https://github.com/apollographql/apollo-server#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apollographql/apollo-server/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf lib",
|
||||
"compile": "tsc",
|
||||
"prepublish": "npm run clean && npm run compile",
|
||||
"test": "jest --verbose"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-server-env": "2.0.0-beta.7",
|
||||
"http-cache-semantics": "^4.0.0",
|
||||
"lru-cache": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^23.0.0",
|
||||
"@types/lru-cache": "^4.1.1",
|
||||
"jest": "^23.1.0",
|
||||
"ts-jest": "^22.4.6"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"transform": {
|
||||
"^.+\\.(ts|js)$": "ts-jest"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js",
|
||||
"json"
|
||||
],
|
||||
"testRegex": "/__tests__/.*$",
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"skipBabel": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
118
packages/apollo-datasource-rest/src/HTTPCache.ts
Normal file
118
packages/apollo-datasource-rest/src/HTTPCache.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import CachePolicy from 'http-cache-semantics';
|
||||
|
||||
import { KeyValueCache } from './KeyValueCache';
|
||||
import { InMemoryKeyValueCache } from './InMemoryKeyValueCache';
|
||||
|
||||
export class HTTPCache {
|
||||
constructor(
|
||||
private keyValueCache: KeyValueCache = new InMemoryKeyValueCache(),
|
||||
) {}
|
||||
|
||||
async fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input, init);
|
||||
|
||||
const cacheKey = cacheKeyFor(request);
|
||||
|
||||
const entry = await this.keyValueCache.get(cacheKey);
|
||||
if (!entry) {
|
||||
const response = await fetch(request);
|
||||
|
||||
const policy = new CachePolicy(
|
||||
policyRequestFrom(request),
|
||||
policyResponseFrom(response),
|
||||
);
|
||||
|
||||
return this.storeResponseAndReturnClone(request, response, policy);
|
||||
}
|
||||
|
||||
const { policy: policyRaw, body } = JSON.parse(entry);
|
||||
|
||||
const policy = CachePolicy.fromObject(policyRaw);
|
||||
|
||||
if (policy.satisfiesWithoutRevalidation(policyRequestFrom(request))) {
|
||||
const headers = policy.responseHeaders();
|
||||
return new Response(body, { status: policy._status, headers });
|
||||
} else {
|
||||
const revalidationHeaders = policy.revalidationHeaders(
|
||||
policyRequestFrom(request),
|
||||
);
|
||||
const revalidationRequest = new Request(request, {
|
||||
headers: revalidationHeaders,
|
||||
});
|
||||
const revalidationResponse = await fetch(revalidationRequest);
|
||||
|
||||
const { policy: revalidatedPolicy, modified } = policy.revalidatedPolicy(
|
||||
policyRequestFrom(revalidationRequest),
|
||||
policyResponseFrom(revalidationResponse),
|
||||
);
|
||||
|
||||
return this.storeResponseAndReturnClone(
|
||||
revalidationRequest,
|
||||
modified
|
||||
? revalidationResponse
|
||||
: new Response(body, {
|
||||
status: revalidatedPolicy._status,
|
||||
headers: revalidatedPolicy.responseHeaders(),
|
||||
}),
|
||||
revalidatedPolicy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async storeResponseAndReturnClone(
|
||||
request: Request,
|
||||
response: Response,
|
||||
policy: CachePolicy,
|
||||
): Promise<Response> {
|
||||
if (!response.headers.has('Cache-Control') || !policy.storable())
|
||||
return response;
|
||||
|
||||
const cacheKey = cacheKeyFor(request);
|
||||
|
||||
const body = await response.text();
|
||||
const entry = JSON.stringify({ policy: policy.toObject(), body });
|
||||
|
||||
await this.keyValueCache.set(cacheKey, entry);
|
||||
|
||||
// We have to clone the response before returning it because the
|
||||
// body can only be used once.
|
||||
// To avoid https://github.com/bitinn/node-fetch/issues/151, we don't use
|
||||
// response.clone() but create a new response from the consumed body
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: policy.responseHeaders(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKeyFor(request: Request): string {
|
||||
// FIXME: Find a way to take Vary header fields into account when computing a cache key
|
||||
// Although we do validate header fields and don't serve responses from cache when they don't match,
|
||||
// new reponses overwrite old ones with different vary header fields.
|
||||
// (I think we have similar heuristics in the Engine proxy)
|
||||
return `httpcache:${request.url}`;
|
||||
}
|
||||
|
||||
function policyRequestFrom(request: Request) {
|
||||
return {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: headersToObject(request.headers),
|
||||
};
|
||||
}
|
||||
|
||||
function policyResponseFrom(response: Response) {
|
||||
return {
|
||||
status: response.status,
|
||||
headers: headersToObject(response.headers),
|
||||
};
|
||||
}
|
||||
|
||||
function headersToObject(headers: Headers) {
|
||||
const object = Object.create(null);
|
||||
for (const [name, value] of headers as Headers) {
|
||||
object[name] = value;
|
||||
}
|
||||
return object;
|
||||
}
|
21
packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts
Normal file
21
packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import LRU from 'lru-cache';
|
||||
import { KeyValueCache } from './KeyValueCache';
|
||||
|
||||
export class InMemoryKeyValueCache implements KeyValueCache {
|
||||
private store: LRU.Cache<string, string>;
|
||||
|
||||
// FIXME: Define reasonable default max size of the cache
|
||||
constructor(maxSize: number = Infinity) {
|
||||
this.store = LRU({
|
||||
max: maxSize,
|
||||
length: item => item.length,
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
return this.store.get(key);
|
||||
}
|
||||
async set(key: string, value: string) {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
}
|
4
packages/apollo-datasource-rest/src/KeyValueCache.ts
Normal file
4
packages/apollo-datasource-rest/src/KeyValueCache.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface KeyValueCache {
|
||||
get(key: string): Promise<string | undefined>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
}
|
102
packages/apollo-datasource-rest/src/RESTDataSource.ts
Normal file
102
packages/apollo-datasource-rest/src/RESTDataSource.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { HTTPCache } from './HTTPCache';
|
||||
|
||||
export type Params = { [name: string]: any };
|
||||
|
||||
export abstract class RESTDataSource<TContext = any> {
|
||||
abstract baseURL: string;
|
||||
|
||||
httpCache!: HTTPCache;
|
||||
context!: TContext;
|
||||
|
||||
public willSendRequest?(request: Request): void;
|
||||
|
||||
public willReceiveCache(httpCache: HTTPCache) {
|
||||
this.httpCache = httpCache;
|
||||
}
|
||||
|
||||
public willReceiveContext(context: TContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
protected async get(
|
||||
path: string,
|
||||
params?: Params,
|
||||
options?: RequestInit,
|
||||
): Promise<any> {
|
||||
return this.fetch(path, params, Object.assign({ method: 'GET' }, options));
|
||||
}
|
||||
|
||||
protected async post(
|
||||
path: string,
|
||||
params?: Params,
|
||||
options?: RequestInit,
|
||||
): Promise<any> {
|
||||
return this.fetch(path, params, Object.assign({ method: 'POST' }, options));
|
||||
}
|
||||
|
||||
protected async put(
|
||||
path: string,
|
||||
params?: Params,
|
||||
options?: RequestInit,
|
||||
): Promise<any> {
|
||||
return this.fetch(path, params, Object.assign({ method: 'PUT' }, options));
|
||||
}
|
||||
|
||||
protected async delete(
|
||||
path: string,
|
||||
params?: Params,
|
||||
options?: RequestInit,
|
||||
): Promise<any> {
|
||||
return this.fetch(
|
||||
path,
|
||||
params,
|
||||
Object.assign({ method: 'DELETE' }, options),
|
||||
);
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
path: string,
|
||||
params?: Params,
|
||||
init?: RequestInit,
|
||||
): Promise<any> {
|
||||
const url = new URL(path, this.baseURL);
|
||||
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
// Append params to existing params in the path
|
||||
for (const [name, value] of new URLSearchParams(params)) {
|
||||
url.searchParams.append(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
return this.trace(`${(init && init.method) || 'GET'} ${url}`, async () => {
|
||||
const request = new Request(String(url));
|
||||
if (this.willSendRequest) {
|
||||
this.willSendRequest(request);
|
||||
}
|
||||
const response = await this.httpCache.fetch(request, init);
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error(
|
||||
`${response.status} ${response.statusText}: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async trace<Result>(
|
||||
label: string,
|
||||
fn: () => Promise<Result>,
|
||||
): Promise<Result> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
const duration = Date.now() - startTime;
|
||||
//to remove the unused error
|
||||
label;
|
||||
duration;
|
||||
// console.log(`${label} (${duration}ms)`);
|
||||
}
|
||||
}
|
||||
}
|
32
packages/apollo-datasource-rest/src/__mocks__/date.ts
Normal file
32
packages/apollo-datasource-rest/src/__mocks__/date.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
const RealDate = global.Date;
|
||||
|
||||
export function mockDate() {
|
||||
global.Date = new Proxy(RealDate, handler);
|
||||
}
|
||||
|
||||
export function unmockDate() {
|
||||
global.Date = RealDate;
|
||||
}
|
||||
|
||||
let now = Date.now();
|
||||
|
||||
export function advanceTimeBy(ms: number) {
|
||||
now += ms;
|
||||
}
|
||||
|
||||
const handler = {
|
||||
construct(target, args) {
|
||||
if (args.length === 0) {
|
||||
return new Date(now);
|
||||
} else {
|
||||
return new target(...args);
|
||||
}
|
||||
},
|
||||
get(target, propKey, receiver) {
|
||||
if (propKey === 'now') {
|
||||
return () => now;
|
||||
} else {
|
||||
return target[propKey];
|
||||
}
|
||||
},
|
||||
};
|
51
packages/apollo-datasource-rest/src/__mocks__/fetch.ts
Normal file
51
packages/apollo-datasource-rest/src/__mocks__/fetch.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
fetch: typeof fetch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Headers = { [name: string]: string };
|
||||
|
||||
interface FetchMock extends jest.Mock<typeof fetch> {
|
||||
mockResponseOnce(data?: any, headers?: Headers, status?: number);
|
||||
mockJSONResponseOnce(data?: object, headers?: Headers);
|
||||
}
|
||||
|
||||
const fetchMock = jest.fn<typeof fetch>() as FetchMock;
|
||||
|
||||
fetchMock.mockResponseOnce = (
|
||||
data?: BodyInit,
|
||||
headers?: Headers,
|
||||
status: number = 200,
|
||||
) => {
|
||||
return fetchMock.mockImplementationOnce(async () => {
|
||||
return new Response(data, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
fetchMock.mockJSONResponseOnce = (
|
||||
data = {},
|
||||
headers?: Headers,
|
||||
status?: number,
|
||||
) => {
|
||||
return fetchMock.mockResponseOnce(
|
||||
JSON.stringify(data),
|
||||
Object.assign({ 'Content-Type': 'application/json' }, headers),
|
||||
status,
|
||||
);
|
||||
};
|
||||
|
||||
export default fetchMock;
|
||||
|
||||
export function mockFetch() {
|
||||
global.fetch = fetchMock;
|
||||
}
|
||||
|
||||
export function unmockFetch() {
|
||||
global.fetch = fetch;
|
||||
}
|
237
packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts
Normal file
237
packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts
Normal file
|
@ -0,0 +1,237 @@
|
|||
import 'apollo-server-env';
|
||||
import { HTTPCache } from '../HTTPCache';
|
||||
|
||||
import fetch, { mockFetch, unmockFetch } from '../__mocks__/fetch';
|
||||
import { mockDate, unmockDate, advanceTimeBy } from '../__mocks__/date';
|
||||
|
||||
describe('HTTPCache', () => {
|
||||
let store: Map<string, string>;
|
||||
let httpCache: HTTPCache;
|
||||
|
||||
beforeAll(() => {
|
||||
mockFetch();
|
||||
mockDate();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetch.mockReset();
|
||||
|
||||
store = new Map();
|
||||
httpCache = new HTTPCache({
|
||||
async get(key: string) {
|
||||
return store.get(key);
|
||||
},
|
||||
async set(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
unmockFetch();
|
||||
unmockDate();
|
||||
});
|
||||
|
||||
it('fetches a response from the origin when not cached', async () => {
|
||||
fetch.mockJSONResponseOnce({ name: 'Ada Lovelace' });
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(await response.json()).toEqual({ name: 'Ada Lovelace' });
|
||||
});
|
||||
|
||||
it('returns a cached response when not expired', async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{ 'Cache-Control': 'max-age=30' },
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
advanceTimeBy(10000);
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(await response.json()).toEqual({ name: 'Ada Lovelace' });
|
||||
expect(response.headers.get('Age')).toEqual('10');
|
||||
});
|
||||
|
||||
it('fetches a fresh response from the origin when expired', async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{ 'Cache-Control': 'max-age=30' },
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
advanceTimeBy(30000);
|
||||
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Alan Turing' },
|
||||
{ 'Cache-Control': 'max-age=30' },
|
||||
);
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
|
||||
expect(await response.json()).toEqual({ name: 'Alan Turing' });
|
||||
expect(response.headers.get('Age')).toEqual('0');
|
||||
});
|
||||
|
||||
it('does not store a response with a non-success status code', async () => {
|
||||
fetch.mockResponseOnce(
|
||||
'Internal server error',
|
||||
{ 'Cache-Control': 'max-age=30' },
|
||||
500,
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(store.size).toEqual(0);
|
||||
});
|
||||
|
||||
it('does not store a response without Cache-Control header', async () => {
|
||||
fetch.mockJSONResponseOnce({ name: 'Ada Lovelace' });
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(store.size).toEqual(0);
|
||||
});
|
||||
|
||||
it('does not store a private response', async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{ 'Cache-Control': 'private, max-age: 60' },
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/me');
|
||||
|
||||
expect(store.size).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns a cached response when Vary header fields match', async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{ 'Cache-Control': 'max-age=30', Vary: 'Accept-Language' },
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1', {
|
||||
headers: { 'Accept-Language': 'en' },
|
||||
});
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1', {
|
||||
headers: { 'Accept-Language': 'en' },
|
||||
});
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(await response.json()).toEqual({ name: 'Ada Lovelace' });
|
||||
});
|
||||
|
||||
it(`does not return a cached response when Vary header fields don't match`, async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{ 'Cache-Control': 'max-age=30', Vary: 'Accept-Language' },
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1', {
|
||||
headers: { 'Accept-Language': 'en' },
|
||||
});
|
||||
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Alan Turing' },
|
||||
{ 'Cache-Control': 'max-age=30' },
|
||||
);
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1', {
|
||||
headers: { 'Accept-Language': 'fr' },
|
||||
});
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
expect(await response.json()).toEqual({ name: 'Alan Turing' });
|
||||
});
|
||||
|
||||
it('revalidates a cached response when expired and returns the cached response when not modified', async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{
|
||||
'Cache-Control': 'public, max-age=30',
|
||||
ETag: 'foo',
|
||||
},
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
advanceTimeBy(30000);
|
||||
|
||||
fetch.mockResponseOnce(
|
||||
null,
|
||||
{
|
||||
'Cache-Control': 'public, max-age=30',
|
||||
ETag: 'foo',
|
||||
},
|
||||
304,
|
||||
);
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
expect(fetch.mock.calls[1][0].headers.get('If-None-Match')).toEqual('foo');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(await response.json()).toEqual({ name: 'Ada Lovelace' });
|
||||
expect(response.headers.get('Age')).toEqual('0');
|
||||
|
||||
advanceTimeBy(10000);
|
||||
|
||||
const response2 = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
|
||||
expect(response2.status).toEqual(200);
|
||||
expect(await response2.json()).toEqual({ name: 'Ada Lovelace' });
|
||||
expect(response2.headers.get('Age')).toEqual('10');
|
||||
});
|
||||
|
||||
it('revalidates a cached response when expired and returns and caches a fresh response when modified', async () => {
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Ada Lovelace' },
|
||||
{
|
||||
'Cache-Control': 'public, max-age=30',
|
||||
ETag: 'foo',
|
||||
},
|
||||
);
|
||||
|
||||
await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
advanceTimeBy(30000);
|
||||
|
||||
fetch.mockJSONResponseOnce(
|
||||
{ name: 'Alan Turing' },
|
||||
{
|
||||
'Cache-Control': 'public, max-age=30',
|
||||
ETag: 'bar',
|
||||
},
|
||||
);
|
||||
|
||||
const response = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
expect(fetch.mock.calls[1][0].headers.get('If-None-Match')).toEqual('foo');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(await response.json()).toEqual({ name: 'Alan Turing' });
|
||||
|
||||
advanceTimeBy(10000);
|
||||
|
||||
const response2 = await httpCache.fetch('https://api.example.com/people/1');
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
|
||||
expect(response2.status).toEqual(200);
|
||||
expect(await response2.json()).toEqual({ name: 'Alan Turing' });
|
||||
expect(response2.headers.get('Age')).toEqual('10');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,138 @@
|
|||
import 'apollo-server-env';
|
||||
import { RESTDataSource } from '../RESTDataSource';
|
||||
|
||||
import fetch, { mockFetch, unmockFetch } from '../__mocks__/fetch';
|
||||
import { HTTPCache } from '../HTTPCache';
|
||||
|
||||
describe('RESTDataSource', () => {
|
||||
const store = new Map<string, string>();
|
||||
let httpCache: HTTPCache;
|
||||
|
||||
beforeAll(() => {
|
||||
mockFetch();
|
||||
|
||||
httpCache = new HTTPCache({
|
||||
async get(key: string) {
|
||||
return store.get(key);
|
||||
},
|
||||
async set(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetch.mockReset();
|
||||
store.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
unmockFetch();
|
||||
});
|
||||
|
||||
it('returns data as parsed JSON', async () => {
|
||||
const dataSource = new class extends RESTDataSource {
|
||||
baseURL = 'https://api.example.com';
|
||||
|
||||
getFoo() {
|
||||
return this.get('foo');
|
||||
}
|
||||
}();
|
||||
|
||||
dataSource.httpCache = httpCache;
|
||||
|
||||
fetch.mockJSONResponseOnce({ foo: 'bar' });
|
||||
|
||||
const data = await dataSource.getFoo();
|
||||
|
||||
expect(data).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('allows adding query string parameters', async () => {
|
||||
const dataSource = new class extends RESTDataSource {
|
||||
baseURL = 'https://api.example.com';
|
||||
|
||||
getPostsForUser(
|
||||
username: string,
|
||||
params: { filter: string; limit: number; offset: number },
|
||||
) {
|
||||
return this.get('posts', Object.assign({ username }, params));
|
||||
}
|
||||
}();
|
||||
|
||||
dataSource.httpCache = httpCache;
|
||||
|
||||
fetch.mockJSONResponseOnce();
|
||||
|
||||
await dataSource.getPostsForUser('beyoncé', {
|
||||
filter: 'jalapeño',
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0].url).toEqual(
|
||||
'https://api.example.com/posts?username=beyonc%C3%A9&filter=jalape%C3%B1o&limit=10&offset=20',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows setting request headers', async () => {
|
||||
const dataSource = new class extends RESTDataSource {
|
||||
baseURL = 'https://api.example.com';
|
||||
|
||||
willSendRequest(request: Request) {
|
||||
request.headers.set('Authorization', 'secret');
|
||||
}
|
||||
|
||||
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].headers.get('Authorization')).toEqual(
|
||||
'secret',
|
||||
);
|
||||
});
|
||||
|
||||
for (const method of ['GET', 'POST', 'PUT', 'DELETE']) {
|
||||
const dataSource = new class extends RESTDataSource {
|
||||
baseURL = 'https://api.example.com';
|
||||
|
||||
getFoo() {
|
||||
return this.get('foo');
|
||||
}
|
||||
|
||||
postFoo() {
|
||||
return this.post('foo');
|
||||
}
|
||||
|
||||
putFoo() {
|
||||
return this.put('foo');
|
||||
}
|
||||
|
||||
deleteFoo() {
|
||||
return this.delete('foo');
|
||||
}
|
||||
}();
|
||||
|
||||
it(`allows performing ${method} requests`, async () => {
|
||||
dataSource.httpCache = httpCache;
|
||||
|
||||
fetch.mockJSONResponseOnce({ foo: 'bar' });
|
||||
|
||||
const data = await dataSource[`${method.toLocaleLowerCase()}Foo`]();
|
||||
|
||||
expect(data).toEqual({ foo: 'bar' });
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0].method).toEqual(method);
|
||||
});
|
||||
}
|
||||
});
|
6
packages/apollo-datasource-rest/src/index.ts
Normal file
6
packages/apollo-datasource-rest/src/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import 'apollo-server-env';
|
||||
|
||||
export { RESTDataSource } from './RESTDataSource';
|
||||
export { HTTPCache } from './HTTPCache';
|
||||
export { KeyValueCache } from './KeyValueCache';
|
||||
export { InMemoryKeyValueCache } from './InMemoryKeyValueCache';
|
36
packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts
vendored
Normal file
36
packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
declare module 'http-cache-semantics' {
|
||||
interface Request {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
interface Response {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
type Headers = { [name: string]: string };
|
||||
|
||||
class CachePolicy {
|
||||
constructor(request: Request, response: Response);
|
||||
|
||||
storable(): boolean;
|
||||
|
||||
satisfiesWithoutRevalidation(request: Request): boolean;
|
||||
responseHeaders(): Headers;
|
||||
|
||||
revalidationHeaders(request: Request): Headers;
|
||||
revalidatedPolicy(
|
||||
request: Request,
|
||||
response: Response,
|
||||
): { policy: CachePolicy; modified: boolean };
|
||||
|
||||
static fromObject(object: object): CachePolicy;
|
||||
toObject(): object;
|
||||
|
||||
_status: number;
|
||||
}
|
||||
|
||||
export = CachePolicy;
|
||||
}
|
16
packages/apollo-datasource-rest/tsconfig.json
Normal file
16
packages/apollo-datasource-rest/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"],
|
||||
"types": ["node", "jest"]
|
||||
}
|
10
packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts
vendored
Normal file
10
packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
interface FetchEvent {
|
||||
readonly request: Request;
|
||||
respondWith(response: Promise<Response>): void;
|
||||
waitUntil(task: Promise<any>): void;
|
||||
}
|
||||
|
||||
declare function addEventListener(
|
||||
type: 'fetch',
|
||||
listener: (event: FetchEvent) => void,
|
||||
): void;
|
|
@ -5,5 +5,6 @@
|
|||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"types": []
|
||||
}
|
||||
|
|
|
@ -53,6 +53,8 @@
|
|||
"dependencies": {
|
||||
"apollo-cache-control": "^0.1.1",
|
||||
"apollo-engine-reporting": "0.0.0-beta.12",
|
||||
"apollo-datasource-rest": "^2.0.0-beta.7",
|
||||
"apollo-server-env": "2.0.0-beta.7",
|
||||
"apollo-tracing": "^0.2.0-beta.1",
|
||||
"graphql-extensions": "0.1.0-beta.13",
|
||||
"graphql-subscriptions": "^0.5.8",
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from 'graphql';
|
||||
import { GraphQLExtension } from 'graphql-extensions';
|
||||
import { EngineReportingAgent } from 'apollo-engine-reporting';
|
||||
import { InMemoryKeyValueCache } from 'apollo-datasource-rest';
|
||||
|
||||
import {
|
||||
SubscriptionServer,
|
||||
|
@ -121,6 +122,10 @@ export class ApolloServerBase {
|
|||
delete requestOptions.persistedQueries;
|
||||
}
|
||||
|
||||
if (!requestOptions.cache) {
|
||||
requestOptions.cache = new InMemoryKeyValueCache();
|
||||
}
|
||||
|
||||
this.requestOptions = requestOptions as GraphQLOptions;
|
||||
this.context = context;
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
import { LogFunction } from './logging';
|
||||
import { PersistedQueryCache } from './caching';
|
||||
import { GraphQLExtension } from 'graphql-extensions';
|
||||
import { RESTDataSource, KeyValueCache } from 'apollo-datasource-rest';
|
||||
|
||||
/*
|
||||
* GraphQLServerOptions
|
||||
|
@ -42,9 +43,13 @@ export interface GraphQLServerOptions<
|
|||
// cacheControl?: boolean | CacheControlExtensionOptions;
|
||||
cacheControl?: boolean | any;
|
||||
extensions?: Array<() => GraphQLExtension>;
|
||||
dataSources?: () => DataSources;
|
||||
cache?: KeyValueCache;
|
||||
persistedQueries?: PersistedQueryOptions;
|
||||
}
|
||||
|
||||
export type DataSources = { [name: string]: RESTDataSource };
|
||||
|
||||
export interface PersistedQueryOptions {
|
||||
cache: PersistedQueryCache;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'apollo-server-env';
|
||||
export { runQuery } from './runQuery';
|
||||
export { LogFunction, LogMessage, LogStep, LogAction } from './logging';
|
||||
export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery';
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
PersistedQueryNotFoundError,
|
||||
} from './errors';
|
||||
import { LogAction, LogStep } from './logging';
|
||||
import { HTTPCache } from 'apollo-datasource-rest';
|
||||
|
||||
export interface HttpQueryRequest {
|
||||
method: string;
|
||||
|
@ -284,6 +285,26 @@ export async function runHttpQuery(
|
|||
);
|
||||
}
|
||||
|
||||
if (optionsObject.dataSources) {
|
||||
const dataSources = optionsObject.dataSources() || {};
|
||||
|
||||
//we use the cache provided to the request and add the Http semantics on top
|
||||
const httpCache = new HTTPCache(optionsObject.cache);
|
||||
|
||||
for (const dataSource of Object.values(dataSources)) {
|
||||
dataSource.willReceiveContext(context);
|
||||
dataSource.willReceiveCache(httpCache);
|
||||
}
|
||||
|
||||
if ('dataSources' in context) {
|
||||
throw new Error(
|
||||
'Please use the dataSources config option instead of putting dataSources on the context yourself.',
|
||||
);
|
||||
}
|
||||
|
||||
(context as any).dataSources = dataSources;
|
||||
}
|
||||
|
||||
let params: QueryOptions = {
|
||||
schema: optionsObject.schema,
|
||||
queryString,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { GraphQLSchema, DocumentNode } from 'graphql';
|
||||
import { SchemaDirectiveVisitor, IResolvers, IMocks } from 'graphql-tools';
|
||||
import { ConnectionContext } from 'subscriptions-transport-ws';
|
||||
import WebSocket from 'ws';
|
||||
import { GraphQLExtension } from 'graphql-extensions';
|
||||
import { EngineReportingOptions } from 'apollo-engine-reporting';
|
||||
export { GraphQLExtension } from 'graphql-extensions';
|
||||
|
@ -10,6 +11,8 @@ import {
|
|||
PersistedQueryOptions,
|
||||
} from './graphqlOptions';
|
||||
|
||||
export { KeyValueCache } from 'apollo-datasource-rest';
|
||||
|
||||
export type Context<T = any> = T;
|
||||
export type ContextFunction<T = any> = (
|
||||
context: Context<T>,
|
||||
|
@ -39,6 +42,8 @@ export interface Config
|
|||
| 'fieldResolver'
|
||||
| 'cacheControl'
|
||||
| 'tracing'
|
||||
| 'dataSources'
|
||||
| 'cache'
|
||||
> {
|
||||
typeDefs?: DocumentNode | [DocumentNode];
|
||||
resolvers?: IResolvers;
|
||||
|
|
6
packages/apollo-server-env/.npmignore
Normal file
6
packages/apollo-server-env/.npmignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
*
|
||||
!src/**/*
|
||||
!dist/**/*
|
||||
dist/**/*.test.*
|
||||
!package.json
|
||||
!README.md
|
27
packages/apollo-server-env/package.json
Normal file
27
packages/apollo-server-env/package.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "apollo-server-env",
|
||||
"version": "2.0.0-beta.7",
|
||||
"author": "opensource@apollographql.com",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-env"
|
||||
},
|
||||
"homepage": "https://github.com/apollographql/apollo-server#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apollographql/apollo-server/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf lib",
|
||||
"compile": "tsc",
|
||||
"prepublish": "npm run clean && npm run compile"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.1.2"
|
||||
}
|
||||
}
|
3
packages/apollo-server-env/src/index.ts
Normal file
3
packages/apollo-server-env/src/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import './polyfills/Object.values';
|
||||
import './polyfills/fetch';
|
||||
import './polyfills/url';
|
13
packages/apollo-server-env/src/polyfills/Object.values.ts
Normal file
13
packages/apollo-server-env/src/polyfills/Object.values.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
interface ObjectConstructor {
|
||||
/**
|
||||
* Returns an array of values of the enumerable properties of an object
|
||||
* @param o Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
|
||||
*/
|
||||
values<T>(o: { [s: string]: T } | ArrayLike<T>): T[];
|
||||
}
|
||||
|
||||
if (!global.Object.values) {
|
||||
global.Object.values = function(o) {
|
||||
return Object.keys(o).map(key => o[key]);
|
||||
};
|
||||
}
|
114
packages/apollo-server-env/src/polyfills/fetch.ts
Normal file
114
packages/apollo-server-env/src/polyfills/fetch.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import fetch, { Request, Response, Headers } from 'node-fetch';
|
||||
|
||||
Object.assign(global, { fetch, Request, Response, Headers });
|
||||
|
||||
declare global {
|
||||
function fetch(input?: RequestInfo, init?: RequestInit): Promise<Response>;
|
||||
|
||||
interface GlobalFetch {
|
||||
fetch: typeof fetch;
|
||||
}
|
||||
|
||||
type RequestInfo = Request | string;
|
||||
|
||||
class Headers implements Iterable<[string, string]> {
|
||||
constructor(init?: HeadersInit);
|
||||
|
||||
append(name: string, value: string): void;
|
||||
delete(name: string): void;
|
||||
get(name: string): string | null;
|
||||
has(name: string): boolean;
|
||||
set(name: string, value: string): void;
|
||||
|
||||
entries(): Iterator<[string, string]>;
|
||||
keys(): Iterator<string>;
|
||||
values(): Iterator<[string]>;
|
||||
[Symbol.iterator](): Iterator<[string, string]>;
|
||||
}
|
||||
|
||||
type HeadersInit = Headers | string[][] | { [name: string]: string };
|
||||
|
||||
class Body {
|
||||
readonly bodyUsed: boolean;
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
json(): Promise<any>;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
class Request extends Body {
|
||||
constructor(input: Request | string, init?: RequestInit);
|
||||
|
||||
readonly method: string;
|
||||
readonly url: string;
|
||||
readonly headers: Headers;
|
||||
|
||||
clone(): Request;
|
||||
}
|
||||
|
||||
interface RequestInit {
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: BodyInit;
|
||||
mode?: RequestMode;
|
||||
credentials?: RequestCredentials;
|
||||
cache?: RequestCache;
|
||||
redirect?: RequestRedirect;
|
||||
referrer?: string;
|
||||
referrerPolicy?: ReferrerPolicy;
|
||||
integrity?: string;
|
||||
}
|
||||
|
||||
type RequestMode = 'navigate' | 'same-origin' | 'no-cors' | 'cors';
|
||||
|
||||
type RequestCredentials = 'omit' | 'same-origin' | 'include';
|
||||
|
||||
type RequestCache =
|
||||
| 'default'
|
||||
| 'no-store'
|
||||
| 'reload'
|
||||
| 'no-cache'
|
||||
| 'force-cache'
|
||||
| 'only-if-cached';
|
||||
|
||||
type RequestRedirect = 'follow' | 'error' | 'manual';
|
||||
|
||||
type ReferrerPolicy =
|
||||
| ''
|
||||
| 'no-referrer'
|
||||
| 'no-referrer-when-downgrade'
|
||||
| 'same-origin'
|
||||
| 'origin'
|
||||
| 'strict-origin'
|
||||
| 'origin-when-cross-origin'
|
||||
| 'strict-origin-when-cross-origin'
|
||||
| 'unsafe-url';
|
||||
|
||||
class Response extends Body {
|
||||
constructor(body?: BodyInit, init?: ResponseInit);
|
||||
static error(): Response;
|
||||
static redirect(url: string, status?: number): Response;
|
||||
|
||||
readonly url: string;
|
||||
readonly redirected: boolean;
|
||||
readonly status: number;
|
||||
readonly ok: boolean;
|
||||
readonly statusText: string;
|
||||
readonly headers: Headers;
|
||||
|
||||
clone(): Response;
|
||||
}
|
||||
|
||||
interface ResponseInit {
|
||||
headers?: HeadersInit;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
type BodyInit = ArrayBuffer | string;
|
||||
|
||||
class Blob {
|
||||
type: string;
|
||||
size: number;
|
||||
slice(start?: number, end?: number): Blob;
|
||||
}
|
||||
}
|
47
packages/apollo-server-env/src/polyfills/url.ts
Normal file
47
packages/apollo-server-env/src/polyfills/url.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { URL, URLSearchParams } from 'url';
|
||||
|
||||
Object.assign(global, { URL, URLSearchParams });
|
||||
|
||||
declare global {
|
||||
class URL {
|
||||
constructor(input: string, base?: string | URL);
|
||||
hash: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
href: string;
|
||||
readonly origin: string;
|
||||
password: string;
|
||||
pathname: string;
|
||||
port: string;
|
||||
protocol: string;
|
||||
search: string;
|
||||
readonly searchParams: URLSearchParams;
|
||||
username: string;
|
||||
toString(): string;
|
||||
toJSON(): string;
|
||||
}
|
||||
|
||||
class URLSearchParams implements Iterable<[string, string]> {
|
||||
constructor(
|
||||
init?:
|
||||
| URLSearchParams
|
||||
| string
|
||||
| { [key: string]: string | string[] | undefined }
|
||||
| Iterable<[string, string]>
|
||||
| Array<[string, string]>,
|
||||
);
|
||||
append(name: string, value: string): void;
|
||||
delete(name: string): void;
|
||||
entries(): IterableIterator<[string, string]>;
|
||||
forEach(callback: (value: string, name: string) => void): void;
|
||||
get(name: string): string | null;
|
||||
getAll(name: string): string[];
|
||||
has(name: string): boolean;
|
||||
keys(): IterableIterator<string>;
|
||||
set(name: string, value: string): void;
|
||||
sort(): void;
|
||||
toString(): string;
|
||||
values(): IterableIterator<string>;
|
||||
[Symbol.iterator](): IterableIterator<[string, string]>;
|
||||
}
|
||||
}
|
16
packages/apollo-server-env/tsconfig.json
Normal file
16
packages/apollo-server-env/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"],
|
||||
"types": ["node"]
|
||||
}
|
|
@ -45,6 +45,7 @@
|
|||
"@types/express": "4.16.0",
|
||||
"@types/multer": "1.3.6",
|
||||
"apollo-server-integration-testsuite": "^2.0.0-beta.11",
|
||||
"apollo-datasource-rest": "2.0.0-beta.7",
|
||||
"connect": "3.6.6",
|
||||
"express": "4.16.3",
|
||||
"form-data": "^2.3.2",
|
||||
|
|
157
packages/apollo-server-express/src/datasource.test.ts
Normal file
157
packages/apollo-server-express/src/datasource.test.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
import { expect } from 'chai';
|
||||
import 'mocha';
|
||||
import express from 'express';
|
||||
|
||||
import http from 'http';
|
||||
|
||||
import { RESTDataSource } from 'apollo-datasource-rest';
|
||||
|
||||
import { createApolloFetch } from 'apollo-fetch';
|
||||
import { ApolloServer } from './ApolloServer';
|
||||
|
||||
import { createServerInfo } from 'apollo-server-integration-testsuite';
|
||||
|
||||
const restPort = 4001;
|
||||
|
||||
export class IdAPI extends RESTDataSource {
|
||||
baseURL = `http://localhost:${restPort}/`;
|
||||
|
||||
async getId(id: string) {
|
||||
console.log(id);
|
||||
return this.get(`id/${id}`);
|
||||
}
|
||||
|
||||
async getStringId(id: string) {
|
||||
console.log(id);
|
||||
return this.get(`str/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
//to remove the circular dependency, we reference it directly
|
||||
const gql = require('../../apollo-server/dist/index').gql;
|
||||
|
||||
const typeDefs = gql`
|
||||
type Query {
|
||||
id: String
|
||||
stringId: String
|
||||
}
|
||||
`;
|
||||
|
||||
const resolvers = {
|
||||
Query: {
|
||||
id: async (p, _, { dataSources }) => {
|
||||
p = p; //for ts unused locals
|
||||
return (await dataSources.id.getId('hi')).id;
|
||||
},
|
||||
stringId: async (p, _, { dataSources }) => {
|
||||
p = p; //for ts unused locals
|
||||
return dataSources.id.getStringId('hi');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let restCalls = 0;
|
||||
const restAPI = express();
|
||||
restAPI.use('/id/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
restCalls++;
|
||||
res.header('Cache-Control', 'max-age=2000, public');
|
||||
//currently data sources expect that the response be a parsable object
|
||||
res.write(JSON.stringify({ id }));
|
||||
res.end();
|
||||
});
|
||||
|
||||
//currently data sources expect that the response be an object, so this will fail
|
||||
restAPI.use('/str/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
restCalls++;
|
||||
res.header('Cache-Control', 'max-age=2000, public');
|
||||
res.write(id);
|
||||
res.end();
|
||||
});
|
||||
|
||||
describe('apollo-server-express', () => {
|
||||
let restServer;
|
||||
before(async () => {
|
||||
await new Promise(resolve => {
|
||||
restServer = restAPI.listen(restPort, resolve);
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await restServer.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
restCalls = 0;
|
||||
});
|
||||
|
||||
it('uses the cache', async () => {
|
||||
const server = new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
dataSources: () => ({
|
||||
id: new IdAPI(),
|
||||
}),
|
||||
});
|
||||
const app = express();
|
||||
|
||||
server.applyMiddleware({ app });
|
||||
const httpServer = await new Promise<http.Server>(resolve => {
|
||||
const s = app.listen({ port: 4000 }, () => resolve(s));
|
||||
});
|
||||
const { url: uri } = createServerInfo(server, httpServer);
|
||||
|
||||
const apolloFetch = createApolloFetch({ uri });
|
||||
const firstResult = await apolloFetch({ query: '{id}' });
|
||||
console.log(firstResult);
|
||||
|
||||
expect(firstResult.data).to.deep.equal({ id: 'hi' });
|
||||
expect(firstResult.errors, 'errors should exist').not.to.exist;
|
||||
expect(restCalls).to.deep.equal(1);
|
||||
|
||||
const secondResult = await apolloFetch({ query: '{id}' });
|
||||
|
||||
expect(secondResult.data).to.deep.equal({ id: 'hi' });
|
||||
expect(secondResult.errors, 'errors should exist').not.to.exist;
|
||||
expect(restCalls).to.deep.equal(1);
|
||||
|
||||
await server.stop();
|
||||
await httpServer.close();
|
||||
});
|
||||
|
||||
//XXX currently this test fails, since data sources parse json
|
||||
// it('can cache a string from the backend', async () => {
|
||||
// const server = new ApolloServer({
|
||||
// typeDefs,
|
||||
// resolvers,
|
||||
// dataSources: () => ({
|
||||
// id: new IdAPI(),
|
||||
// }),
|
||||
// });
|
||||
// const app = express();
|
||||
|
||||
// server.applyMiddleware({ app });
|
||||
// const httpServer = await new Promise<http.Server>(resolve => {
|
||||
// const s = app.listen({ port: 4000 }, () => resolve(s));
|
||||
// });
|
||||
// const { url: uri } = createServerInfo(server, httpServer);
|
||||
|
||||
// const apolloFetch = createApolloFetch({ uri });
|
||||
// const firstResult = await apolloFetch({ query: '{stringId}' });
|
||||
// console.log(firstResult);
|
||||
|
||||
// expect(firstResult.data).to.deep.equal({ id: 'hi' });
|
||||
// expect(firstResult.errors, 'errors should exist').not.to.exist;
|
||||
// expect(restCalls).to.deep.equal(1);
|
||||
|
||||
// const secondResult = await apolloFetch({ query: '{id}' });
|
||||
|
||||
// expect(secondResult.data).to.deep.equal({ id: 'hi' });
|
||||
// expect(secondResult.errors, 'errors should exist').not.to.exist;
|
||||
// expect(restCalls).to.deep.equal(1);
|
||||
|
||||
// await server.stop();
|
||||
// await httpServer.close();
|
||||
// });
|
||||
});
|
|
@ -23,6 +23,7 @@ require('../packages/apollo-server-module-operation-store/dist/operationStore.te
|
|||
require('../packages/apollo-server-express/dist/ApolloServer.test.js');
|
||||
require('../packages/apollo-server-express/dist/expressApollo.test');
|
||||
require('../packages/apollo-server-express/dist/connectApollo.test');
|
||||
require('../packages/apollo-server-express/dist/datasource.test');
|
||||
|
||||
(NODE_MAJOR_VERSION >= 9 ||
|
||||
(NODE_MAJOR_VERSION >= 8 && NODE_MAJOR_REVISION >= 9)) &&
|
||||
|
|
|
@ -6,10 +6,12 @@
|
|||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noImplicitAny": false,
|
||||
"removeComments": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"lib": ["es2016", "esnext.asynciterable", "webworker"]
|
||||
"lib": ["es2016", "esnext.asynciterable"],
|
||||
"types": ["node", "mocha"]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue