diff --git a/.vscode/settings.json b/.vscode/settings.json index 274e9fdc..c29cb70c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, "editor.wordWrapColumn": 110, + "editor.formatOnSave": true, "prettier.singleQuote": true, "prettier.printWidth": 110, "files.exclude": { diff --git a/docs/_config.yml b/docs/_config.yml index 12810dfc..850dba55 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -17,6 +17,7 @@ sidebar_categories: - features/mocking - features/errors - features/apq + - features/data-sources - features/logging - features/scalars-enums - features/unions-interfaces diff --git a/docs/source/features/data-sources.md b/docs/source/features/data-sources.md new file mode 100644 index 00000000..2aac7fb6 --- /dev/null +++ b/docs/source/features/data-sources.md @@ -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", +} +``` diff --git a/lerna.json b/lerna.json index 66c89efe..286ae067 100644 --- a/lerna.json +++ b/lerna.json @@ -13,7 +13,5 @@ } }, "hoist": true, - "packages": [ - "packages/*" - ] + "packages": ["packages/*"] } diff --git a/packages/apollo-datasource-rest/.npmignore b/packages/apollo-datasource-rest/.npmignore new file mode 100644 index 00000000..a165046d --- /dev/null +++ b/packages/apollo-datasource-rest/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-datasource-rest/package.json b/packages/apollo-datasource-rest/package.json new file mode 100644 index 00000000..b09edd1a --- /dev/null +++ b/packages/apollo-datasource-rest/package.json @@ -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 + } + } + } +} diff --git a/packages/apollo-datasource-rest/src/HTTPCache.ts b/packages/apollo-datasource-rest/src/HTTPCache.ts new file mode 100644 index 00000000..196d8f0d --- /dev/null +++ b/packages/apollo-datasource-rest/src/HTTPCache.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts b/packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts new file mode 100644 index 00000000..861de012 --- /dev/null +++ b/packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts @@ -0,0 +1,21 @@ +import LRU from 'lru-cache'; +import { KeyValueCache } from './KeyValueCache'; + +export class InMemoryKeyValueCache implements KeyValueCache { + private store: LRU.Cache; + + // 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); + } +} diff --git a/packages/apollo-datasource-rest/src/KeyValueCache.ts b/packages/apollo-datasource-rest/src/KeyValueCache.ts new file mode 100644 index 00000000..156cd032 --- /dev/null +++ b/packages/apollo-datasource-rest/src/KeyValueCache.ts @@ -0,0 +1,4 @@ +export interface KeyValueCache { + get(key: string): Promise; + set(key: string, value: string): Promise; +} diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts new file mode 100644 index 00000000..57364785 --- /dev/null +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -0,0 +1,102 @@ +import { HTTPCache } from './HTTPCache'; + +export type Params = { [name: string]: any }; + +export abstract class RESTDataSource { + 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 { + return this.fetch(path, params, Object.assign({ method: 'GET' }, options)); + } + + protected async post( + path: string, + params?: Params, + options?: RequestInit, + ): Promise { + return this.fetch(path, params, Object.assign({ method: 'POST' }, options)); + } + + protected async put( + path: string, + params?: Params, + options?: RequestInit, + ): Promise { + return this.fetch(path, params, Object.assign({ method: 'PUT' }, options)); + } + + protected async delete( + path: string, + params?: Params, + options?: RequestInit, + ): Promise { + return this.fetch( + path, + params, + Object.assign({ method: 'DELETE' }, options), + ); + } + + private async fetch( + path: string, + params?: Params, + init?: RequestInit, + ): Promise { + 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( + label: string, + fn: () => Promise, + ): Promise { + 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)`); + } + } +} diff --git a/packages/apollo-datasource-rest/src/__mocks__/date.ts b/packages/apollo-datasource-rest/src/__mocks__/date.ts new file mode 100644 index 00000000..91a150b4 --- /dev/null +++ b/packages/apollo-datasource-rest/src/__mocks__/date.ts @@ -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]; + } + }, +}; diff --git a/packages/apollo-datasource-rest/src/__mocks__/fetch.ts b/packages/apollo-datasource-rest/src/__mocks__/fetch.ts new file mode 100644 index 00000000..8b7778bf --- /dev/null +++ b/packages/apollo-datasource-rest/src/__mocks__/fetch.ts @@ -0,0 +1,51 @@ +declare global { + namespace NodeJS { + interface Global { + fetch: typeof fetch; + } + } +} + +type Headers = { [name: string]: string }; + +interface FetchMock extends jest.Mock { + mockResponseOnce(data?: any, headers?: Headers, status?: number); + mockJSONResponseOnce(data?: object, headers?: Headers); +} + +const fetchMock = jest.fn() 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; +} diff --git a/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts b/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts new file mode 100644 index 00000000..c91ce952 --- /dev/null +++ b/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts @@ -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; + 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'); + }); +}); diff --git a/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts b/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts new file mode 100644 index 00000000..63a762bd --- /dev/null +++ b/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts @@ -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(); + 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); + }); + } +}); diff --git a/packages/apollo-datasource-rest/src/index.ts b/packages/apollo-datasource-rest/src/index.ts new file mode 100644 index 00000000..c9562bc6 --- /dev/null +++ b/packages/apollo-datasource-rest/src/index.ts @@ -0,0 +1,6 @@ +import 'apollo-server-env'; + +export { RESTDataSource } from './RESTDataSource'; +export { HTTPCache } from './HTTPCache'; +export { KeyValueCache } from './KeyValueCache'; +export { InMemoryKeyValueCache } from './InMemoryKeyValueCache'; diff --git a/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts b/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts new file mode 100644 index 00000000..7ae98ed9 --- /dev/null +++ b/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts @@ -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; +} diff --git a/packages/apollo-datasource-rest/tsconfig.json b/packages/apollo-datasource-rest/tsconfig.json new file mode 100644 index 00000000..3cc3ec76 --- /dev/null +++ b/packages/apollo-datasource-rest/tsconfig.json @@ -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"] +} diff --git a/packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts b/packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts new file mode 100644 index 00000000..e356bac0 --- /dev/null +++ b/packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts @@ -0,0 +1,10 @@ +interface FetchEvent { + readonly request: Request; + respondWith(response: Promise): void; + waitUntil(task: Promise): void; +} + +declare function addEventListener( + type: 'fetch', + listener: (event: FetchEvent) => void, +): void; diff --git a/packages/apollo-server-cloudflare/tsconfig.json b/packages/apollo-server-cloudflare/tsconfig.json index b475937e..2d35d9da 100644 --- a/packages/apollo-server-cloudflare/tsconfig.json +++ b/packages/apollo-server-cloudflare/tsconfig.json @@ -5,5 +5,6 @@ "outDir": "./dist" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], + "types": [] } diff --git a/packages/apollo-server-core/package.json b/packages/apollo-server-core/package.json index 9e1f3c91..8502455f 100644 --- a/packages/apollo-server-core/package.json +++ b/packages/apollo-server-core/package.json @@ -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", diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index c84d925e..a7a0aa22 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -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; diff --git a/packages/apollo-server-core/src/graphqlOptions.ts b/packages/apollo-server-core/src/graphqlOptions.ts index 9333504d..60716c13 100644 --- a/packages/apollo-server-core/src/graphqlOptions.ts +++ b/packages/apollo-server-core/src/graphqlOptions.ts @@ -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; } diff --git a/packages/apollo-server-core/src/index.ts b/packages/apollo-server-core/src/index.ts index 289f3f82..63eeabe3 100644 --- a/packages/apollo-server-core/src/index.ts +++ b/packages/apollo-server-core/src/index.ts @@ -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'; diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index 5aa123a0..d48aae60 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -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, diff --git a/packages/apollo-server-core/src/types.ts b/packages/apollo-server-core/src/types.ts index 3558498c..42b2c44a 100644 --- a/packages/apollo-server-core/src/types.ts +++ b/packages/apollo-server-core/src/types.ts @@ -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; export type ContextFunction = ( context: Context, @@ -39,6 +42,8 @@ export interface Config | 'fieldResolver' | 'cacheControl' | 'tracing' + | 'dataSources' + | 'cache' > { typeDefs?: DocumentNode | [DocumentNode]; resolvers?: IResolvers; diff --git a/packages/apollo-server-env/.npmignore b/packages/apollo-server-env/.npmignore new file mode 100644 index 00000000..a165046d --- /dev/null +++ b/packages/apollo-server-env/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server-env/package.json b/packages/apollo-server-env/package.json new file mode 100644 index 00000000..939d3953 --- /dev/null +++ b/packages/apollo-server-env/package.json @@ -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" + } +} diff --git a/packages/apollo-server-env/src/index.ts b/packages/apollo-server-env/src/index.ts new file mode 100644 index 00000000..61cdc011 --- /dev/null +++ b/packages/apollo-server-env/src/index.ts @@ -0,0 +1,3 @@ +import './polyfills/Object.values'; +import './polyfills/fetch'; +import './polyfills/url'; diff --git a/packages/apollo-server-env/src/polyfills/Object.values.ts b/packages/apollo-server-env/src/polyfills/Object.values.ts new file mode 100644 index 00000000..6f3409b2 --- /dev/null +++ b/packages/apollo-server-env/src/polyfills/Object.values.ts @@ -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(o: { [s: string]: T } | ArrayLike): T[]; +} + +if (!global.Object.values) { + global.Object.values = function(o) { + return Object.keys(o).map(key => o[key]); + }; +} diff --git a/packages/apollo-server-env/src/polyfills/fetch.ts b/packages/apollo-server-env/src/polyfills/fetch.ts new file mode 100644 index 00000000..262ea959 --- /dev/null +++ b/packages/apollo-server-env/src/polyfills/fetch.ts @@ -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; + + 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; + values(): Iterator<[string]>; + [Symbol.iterator](): Iterator<[string, string]>; + } + + type HeadersInit = Headers | string[][] | { [name: string]: string }; + + class Body { + readonly bodyUsed: boolean; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; + } + + 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; + } +} diff --git a/packages/apollo-server-env/src/polyfills/url.ts b/packages/apollo-server-env/src/polyfills/url.ts new file mode 100644 index 00000000..c0336e66 --- /dev/null +++ b/packages/apollo-server-env/src/polyfills/url.ts @@ -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; + set(name: string, value: string): void; + sort(): void; + toString(): string; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, string]>; + } +} diff --git a/packages/apollo-server-env/tsconfig.json b/packages/apollo-server-env/tsconfig.json new file mode 100644 index 00000000..f37e6cb5 --- /dev/null +++ b/packages/apollo-server-env/tsconfig.json @@ -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"] +} diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index e132d9c5..ab589af9 100644 --- a/packages/apollo-server-express/package.json +++ b/packages/apollo-server-express/package.json @@ -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", diff --git a/packages/apollo-server-express/src/datasource.test.ts b/packages/apollo-server-express/src/datasource.test.ts new file mode 100644 index 00000000..e155cd3d --- /dev/null +++ b/packages/apollo-server-express/src/datasource.test.ts @@ -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(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(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(); + // }); +}); diff --git a/test/tests.js b/test/tests.js index cda46907..8eb5f847 100644 --- a/test/tests.js +++ b/test/tests.js @@ -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)) && diff --git a/tsconfig.json b/tsconfig.json index a0e80ac5..9b486894 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } }