From 8f74518fbc71e08a68c374f34b9a786fbc22b6ae Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Tue, 12 Jun 2018 10:14:07 +0200 Subject: [PATCH 01/14] Enable declarationMap in tsconfig.json See http://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-9.html --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index a0e80ac5ece..2a36a29f3f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "esModuleInterop": true, "sourceMap": true, "declaration": true, + "declarationMap": true, "noImplicitAny": false, "removeComments": true, "noUnusedLocals": true, From 1c2353579adead339e08fd4f201a66f681caba10 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Tue, 12 Jun 2018 22:35:52 +0200 Subject: [PATCH 02/14] Add apollo-server-caching package and improve typings --- .vscode/settings.json | 1 + packages/apollo-server-caching/package.json | 54 ++++ .../src/__mocks__/date.ts | 32 +++ .../src/__mocks__/fetch.ts | 51 ++++ .../src/__tests__/httpCache.test.ts | 260 ++++++++++++++++++ .../apollo-server-caching/src/httpCache.ts | 112 ++++++++ packages/apollo-server-caching/src/index.ts | 4 + .../src/keyValueCache.ts | 4 + .../src/polyfills/fetch.ts | 17 ++ .../src/polyfills/url.ts | 13 + .../src/types/http-cache-semantics/index.d.ts | 36 +++ packages/apollo-server-caching/tsconfig.json | 16 ++ .../src/types/cloudflare/index.d.ts | 10 + .../apollo-server-cloudflare/tsconfig.json | 3 +- packages/apollo-server-core/src/types.ts | 1 + tsconfig.json | 4 +- types/fetch/index.d.ts | 111 ++++++++ types/url/index.d.ts | 41 +++ 18 files changed, 768 insertions(+), 2 deletions(-) create mode 100644 packages/apollo-server-caching/package.json create mode 100644 packages/apollo-server-caching/src/__mocks__/date.ts create mode 100644 packages/apollo-server-caching/src/__mocks__/fetch.ts create mode 100644 packages/apollo-server-caching/src/__tests__/httpCache.test.ts create mode 100644 packages/apollo-server-caching/src/httpCache.ts create mode 100644 packages/apollo-server-caching/src/index.ts create mode 100644 packages/apollo-server-caching/src/keyValueCache.ts create mode 100644 packages/apollo-server-caching/src/polyfills/fetch.ts create mode 100644 packages/apollo-server-caching/src/polyfills/url.ts create mode 100644 packages/apollo-server-caching/src/types/http-cache-semantics/index.d.ts create mode 100644 packages/apollo-server-caching/tsconfig.json create mode 100644 packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts create mode 100644 types/fetch/index.d.ts create mode 100644 types/url/index.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 274e9fdcd1a..c29cb70c127 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/packages/apollo-server-caching/package.json b/packages/apollo-server-caching/package.json new file mode 100644 index 00000000000..2d386ba8bc5 --- /dev/null +++ b/packages/apollo-server-caching/package.json @@ -0,0 +1,54 @@ +{ + "name": "apollo-server-caching", + "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-caching" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf lib", + "compile": "tsc", + "prepare": "npm run clean && npm run compile", + "test": "jest --verbose" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=8" + }, + "dependencies": { + "http-cache-semantics": "^4.0.0" + }, + "devDependencies": { + "@types/jest": "^23.0.0", + "jest": "^23.1.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "/__tests__/.*$", + "setupFiles": [ + "/src/polyfills/fetch.ts", + "/src/polyfills/url.ts" + ], + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-server-caching/src/__mocks__/date.ts b/packages/apollo-server-caching/src/__mocks__/date.ts new file mode 100644 index 00000000000..91a150b4bb8 --- /dev/null +++ b/packages/apollo-server-caching/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-server-caching/src/__mocks__/fetch.ts b/packages/apollo-server-caching/src/__mocks__/fetch.ts new file mode 100644 index 00000000000..8b7778bfc29 --- /dev/null +++ b/packages/apollo-server-caching/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-server-caching/src/__tests__/httpCache.test.ts b/packages/apollo-server-caching/src/__tests__/httpCache.test.ts new file mode 100644 index 00000000000..6a41067862a --- /dev/null +++ b/packages/apollo-server-caching/src/__tests__/httpCache.test.ts @@ -0,0 +1,260 @@ +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) { + await 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); + + fetch.mockJSONResponseOnce({ name: 'Ada Lovelace' }); + + const response = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(2); + expect(await response.json()).toEqual({ name: 'Ada Lovelace' }); + }); + + 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); + + fetch.mockJSONResponseOnce({ name: 'Alan Turing' }); + + 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' }); + }); + + 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); + + fetch.mockJSONResponseOnce( + { name: 'Alan Turing' }, + { 'Cache-Control': 'private' }, + ); + + const response = await httpCache.fetch('https://api.example.com/me'); + + expect(fetch.mock.calls.length).toEqual(2); + expect(await response.json()).toEqual({ name: 'Alan Turing' }); + }); + + 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-server-caching/src/httpCache.ts b/packages/apollo-server-caching/src/httpCache.ts new file mode 100644 index 00000000000..396d78a121b --- /dev/null +++ b/packages/apollo-server-caching/src/httpCache.ts @@ -0,0 +1,112 @@ +import CachePolicy from 'http-cache-semantics'; + +import { KeyValueCache } from './keyValueCache'; + +export class HTTPCache { + constructor(private keyValueCache: KeyValueCache) {} + + 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, + ); + } + } + + protected 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 + // (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-server-caching/src/index.ts b/packages/apollo-server-caching/src/index.ts new file mode 100644 index 00000000000..c11deb05a46 --- /dev/null +++ b/packages/apollo-server-caching/src/index.ts @@ -0,0 +1,4 @@ +import './polyfills/fetch'; +import './polyfills/url'; + +export { HTTPCache } from './httpCache'; diff --git a/packages/apollo-server-caching/src/keyValueCache.ts b/packages/apollo-server-caching/src/keyValueCache.ts new file mode 100644 index 00000000000..156cd03214e --- /dev/null +++ b/packages/apollo-server-caching/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-server-caching/src/polyfills/fetch.ts b/packages/apollo-server-caching/src/polyfills/fetch.ts new file mode 100644 index 00000000000..2a374195110 --- /dev/null +++ b/packages/apollo-server-caching/src/polyfills/fetch.ts @@ -0,0 +1,17 @@ +declare namespace NodeJS { + interface Global { + fetch: typeof fetch; + Request: typeof Request; + Response: typeof Response; + Headers: typeof Headers; + } +} + +if (!global.fetch) { + const { default: fetch, Request, Response, Headers } = require('node-fetch'); + + global.fetch = fetch; + global.Request = Request; + global.Response = Response; + global.Headers = Headers; +} diff --git a/packages/apollo-server-caching/src/polyfills/url.ts b/packages/apollo-server-caching/src/polyfills/url.ts new file mode 100644 index 00000000000..d3a582ea9ca --- /dev/null +++ b/packages/apollo-server-caching/src/polyfills/url.ts @@ -0,0 +1,13 @@ +declare namespace NodeJS { + interface Global { + URL: typeof URL; + URLSearchParams: typeof URLSearchParams; + } +} + +if (!global.URL) { + const { URL, URLSearchParams } = require('url'); + + global.URL = URL; + global.URLSearchParams = URLSearchParams; +} diff --git a/packages/apollo-server-caching/src/types/http-cache-semantics/index.d.ts b/packages/apollo-server-caching/src/types/http-cache-semantics/index.d.ts new file mode 100644 index 00000000000..7ae98ed9ccf --- /dev/null +++ b/packages/apollo-server-caching/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-server-caching/tsconfig.json b/packages/apollo-server-caching/tsconfig.json new file mode 100644 index 00000000000..9c00a07cfcc --- /dev/null +++ b/packages/apollo-server-caching/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", "fetch", "url", "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 00000000000..e356bac0006 --- /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 564cda66232..8aa9ee6acf6 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": ["fetch", "url"] } diff --git a/packages/apollo-server-core/src/types.ts b/packages/apollo-server-core/src/types.ts index b5aed72dc6b..c4170680238 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 { Server as HttpServer } from 'http'; import { ListenOptions as HttpListenOptions } from 'net'; import { GraphQLExtension } from 'graphql-extensions'; diff --git a/tsconfig.json b/tsconfig.json index 2a36a29f3f5..14e028f394e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,8 @@ "removeComments": true, "noUnusedLocals": true, "noUnusedParameters": true, - "lib": ["es2016", "esnext.asynciterable", "webworker"] + "lib": ["es2016", "esnext.asynciterable"], + "types": ["node", "fetch", "url", "mocha"], + "typeRoots": ["./node_modules/@types", "./types"] } } diff --git a/types/fetch/index.d.ts b/types/fetch/index.d.ts new file mode 100644 index 00000000000..931253ef865 --- /dev/null +++ b/types/fetch/index.d.ts @@ -0,0 +1,111 @@ +declare function fetch( + input?: RequestInfo, + init?: RequestInit, +): Promise; + +interface GlobalFetch { + fetch: typeof fetch; +} + +type RequestInfo = Request | string; + +declare 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 }; + +declare class Body { + readonly bodyUsed: boolean; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; +} + +declare 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'; + +declare 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; + +declare class Blob { + type: string; + size: number; + slice(start?: number, end?: number): Blob; +} diff --git a/types/url/index.d.ts b/types/url/index.d.ts new file mode 100644 index 00000000000..13945f686ac --- /dev/null +++ b/types/url/index.d.ts @@ -0,0 +1,41 @@ +declare 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; +} + +declare 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]>; +} From f8658b94d9915725088a69098a64a4c08e39a299 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Tue, 12 Jun 2018 23:24:32 +0200 Subject: [PATCH 03/14] Remove superfluous test steps --- .../src/__tests__/httpCache.test.ts | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/apollo-server-caching/src/__tests__/httpCache.test.ts b/packages/apollo-server-caching/src/__tests__/httpCache.test.ts index 6a41067862a..759211c36c4 100644 --- a/packages/apollo-server-caching/src/__tests__/httpCache.test.ts +++ b/packages/apollo-server-caching/src/__tests__/httpCache.test.ts @@ -21,7 +21,7 @@ describe('HTTPCache', () => { return store.get(key); }, async set(key: string, value: string) { - await store.set(key, value); + store.set(key, value); }, }); }); @@ -90,13 +90,6 @@ describe('HTTPCache', () => { await httpCache.fetch('https://api.example.com/people/1'); expect(store.size).toEqual(0); - - fetch.mockJSONResponseOnce({ name: 'Ada Lovelace' }); - - const response = await httpCache.fetch('https://api.example.com/people/1'); - - expect(fetch.mock.calls.length).toEqual(2); - expect(await response.json()).toEqual({ name: 'Ada Lovelace' }); }); it('does not store a response without Cache-Control header', async () => { @@ -105,13 +98,6 @@ describe('HTTPCache', () => { await httpCache.fetch('https://api.example.com/people/1'); expect(store.size).toEqual(0); - - fetch.mockJSONResponseOnce({ name: 'Alan Turing' }); - - 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' }); }); it('does not store a private response', async () => { @@ -123,16 +109,6 @@ describe('HTTPCache', () => { await httpCache.fetch('https://api.example.com/me'); expect(store.size).toEqual(0); - - fetch.mockJSONResponseOnce( - { name: 'Alan Turing' }, - { 'Cache-Control': 'private' }, - ); - - const response = await httpCache.fetch('https://api.example.com/me'); - - expect(fetch.mock.calls.length).toEqual(2); - expect(await response.json()).toEqual({ name: 'Alan Turing' }); }); it('returns a cached response when Vary header fields match', async () => { From 4f25000a6998727d12883cf8883917dc417de99a Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Wed, 13 Jun 2018 11:40:32 +0200 Subject: [PATCH 04/14] Add .npmignore to apollo-server-caching --- packages/apollo-server-caching/.npmignore | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/apollo-server-caching/.npmignore diff --git a/packages/apollo-server-caching/.npmignore b/packages/apollo-server-caching/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server-caching/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md From 395da0f799b5d3d50b291b6eaab213b932f1d854 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Thu, 14 Jun 2018 20:17:23 +0200 Subject: [PATCH 05/14] Add apollo-server-env and apollo-datasource-rest packages --- lerna.json | 4 +- .../.npmignore | 0 .../package.json | 15 +- .../src/HTTPCache.ts} | 11 +- .../src/InMemoryKeyValueCache.ts | 21 +++ .../src/KeyValueCache.ts} | 0 .../src/RESTDataSource.ts | 91 ++++++++++++ .../src/__mocks__/date.ts | 0 .../src/__mocks__/fetch.ts | 0 .../src/__tests__/HTTPCache.test.ts} | 3 +- .../src/__tests__/RESTDataSource.test.ts | 138 ++++++++++++++++++ packages/apollo-datasource-rest/src/index.ts | 6 + .../src/types/http-cache-semantics/index.d.ts | 0 .../tsconfig.json | 2 +- .../src/polyfills/fetch.ts | 17 --- .../src/polyfills/url.ts | 13 -- .../apollo-server-cloudflare/tsconfig.json | 2 +- packages/apollo-server-core/package.json | 2 + .../apollo-server-core/src/graphqlOptions.ts | 4 + packages/apollo-server-core/src/index.ts | 1 + .../apollo-server-core/src/runHttpQuery.ts | 20 +++ packages/apollo-server-core/src/types.ts | 1 + packages/apollo-server-env/.npmignore | 6 + packages/apollo-server-env/package.json | 27 ++++ .../src/index.ts | 3 +- .../src/polyfills/Object.values.ts | 13 ++ .../apollo-server-env/src/polyfills/fetch.ts | 114 +++++++++++++++ .../apollo-server-env/src/polyfills/url.ts | 47 ++++++ packages/apollo-server-env/tsconfig.json | 16 ++ tsconfig.json | 3 +- types/fetch/index.d.ts | 111 -------------- types/url/index.d.ts | 41 ------ 32 files changed, 529 insertions(+), 203 deletions(-) rename packages/{apollo-server-caching => apollo-datasource-rest}/.npmignore (100%) rename packages/{apollo-server-caching => apollo-datasource-rest}/package.json (80%) rename packages/{apollo-server-caching/src/httpCache.ts => apollo-datasource-rest/src/HTTPCache.ts} (89%) create mode 100644 packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts rename packages/{apollo-server-caching/src/keyValueCache.ts => apollo-datasource-rest/src/KeyValueCache.ts} (100%) create mode 100644 packages/apollo-datasource-rest/src/RESTDataSource.ts rename packages/{apollo-server-caching => apollo-datasource-rest}/src/__mocks__/date.ts (100%) rename packages/{apollo-server-caching => apollo-datasource-rest}/src/__mocks__/fetch.ts (100%) rename packages/{apollo-server-caching/src/__tests__/httpCache.test.ts => apollo-datasource-rest/src/__tests__/HTTPCache.test.ts} (98%) create mode 100644 packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts create mode 100644 packages/apollo-datasource-rest/src/index.ts rename packages/{apollo-server-caching => apollo-datasource-rest}/src/types/http-cache-semantics/index.d.ts (100%) rename packages/{apollo-server-caching => apollo-datasource-rest}/tsconfig.json (89%) delete mode 100644 packages/apollo-server-caching/src/polyfills/fetch.ts delete mode 100644 packages/apollo-server-caching/src/polyfills/url.ts create mode 100644 packages/apollo-server-env/.npmignore create mode 100644 packages/apollo-server-env/package.json rename packages/{apollo-server-caching => apollo-server-env}/src/index.ts (56%) create mode 100644 packages/apollo-server-env/src/polyfills/Object.values.ts create mode 100644 packages/apollo-server-env/src/polyfills/fetch.ts create mode 100644 packages/apollo-server-env/src/polyfills/url.ts create mode 100644 packages/apollo-server-env/tsconfig.json delete mode 100644 types/fetch/index.d.ts delete mode 100644 types/url/index.d.ts diff --git a/lerna.json b/lerna.json index 0a8d4eb94c9..a983bcb326b 100644 --- a/lerna.json +++ b/lerna.json @@ -13,7 +13,5 @@ } }, "hoist": true, - "packages": [ - "packages/*" - ] + "packages": ["packages/*"] } diff --git a/packages/apollo-server-caching/.npmignore b/packages/apollo-datasource-rest/.npmignore similarity index 100% rename from packages/apollo-server-caching/.npmignore rename to packages/apollo-datasource-rest/.npmignore diff --git a/packages/apollo-server-caching/package.json b/packages/apollo-datasource-rest/package.json similarity index 80% rename from packages/apollo-server-caching/package.json rename to packages/apollo-datasource-rest/package.json index 2d386ba8bc5..03ecea4c497 100644 --- a/packages/apollo-server-caching/package.json +++ b/packages/apollo-datasource-rest/package.json @@ -1,11 +1,11 @@ { - "name": "apollo-server-caching", + "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-server-caching" + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-datasource-rest" }, "homepage": "https://github.com/apollographql/apollo-server#readme", "bugs": { @@ -20,13 +20,16 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "engines": { - "node": ">=8" + "node": ">=6" }, "dependencies": { - "http-cache-semantics": "^4.0.0" + "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" }, @@ -41,10 +44,6 @@ "json" ], "testRegex": "/__tests__/.*$", - "setupFiles": [ - "/src/polyfills/fetch.ts", - "/src/polyfills/url.ts" - ], "globals": { "ts-jest": { "skipBabel": true diff --git a/packages/apollo-server-caching/src/httpCache.ts b/packages/apollo-datasource-rest/src/HTTPCache.ts similarity index 89% rename from packages/apollo-server-caching/src/httpCache.ts rename to packages/apollo-datasource-rest/src/HTTPCache.ts index 396d78a121b..bef300b7ca2 100644 --- a/packages/apollo-server-caching/src/httpCache.ts +++ b/packages/apollo-datasource-rest/src/HTTPCache.ts @@ -1,12 +1,15 @@ import CachePolicy from 'http-cache-semantics'; -import { KeyValueCache } from './keyValueCache'; +import { KeyValueCache, InMemoryKeyValueCache } from './keyValueCaching'; export class HTTPCache { - constructor(private keyValueCache: KeyValueCache) {} + 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); @@ -55,7 +58,7 @@ export class HTTPCache { } } - protected async storeResponseAndReturnClone( + private async storeResponseAndReturnClone( request: Request, response: Response, policy: CachePolicy, @@ -84,6 +87,8 @@ export class HTTPCache { 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}`; } diff --git a/packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts b/packages/apollo-datasource-rest/src/InMemoryKeyValueCache.ts new file mode 100644 index 00000000000..861de0128b9 --- /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-server-caching/src/keyValueCache.ts b/packages/apollo-datasource-rest/src/KeyValueCache.ts similarity index 100% rename from packages/apollo-server-caching/src/keyValueCache.ts rename to packages/apollo-datasource-rest/src/KeyValueCache.ts diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts new file mode 100644 index 00000000000..45fe9b91c46 --- /dev/null +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -0,0 +1,91 @@ +import { HTTPCache } from './HTTPCache'; + +export type Params = { [name: string]: any }; + +export abstract class RESTDataSource { + abstract baseURL: string; + + httpCache!: HTTPCache; + context!: TContext; + + willSendRequest?(request: Request): void; + + 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; + console.log(`${label} (${duration}ms)`); + } + } +} diff --git a/packages/apollo-server-caching/src/__mocks__/date.ts b/packages/apollo-datasource-rest/src/__mocks__/date.ts similarity index 100% rename from packages/apollo-server-caching/src/__mocks__/date.ts rename to packages/apollo-datasource-rest/src/__mocks__/date.ts diff --git a/packages/apollo-server-caching/src/__mocks__/fetch.ts b/packages/apollo-datasource-rest/src/__mocks__/fetch.ts similarity index 100% rename from packages/apollo-server-caching/src/__mocks__/fetch.ts rename to packages/apollo-datasource-rest/src/__mocks__/fetch.ts diff --git a/packages/apollo-server-caching/src/__tests__/httpCache.test.ts b/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts similarity index 98% rename from packages/apollo-server-caching/src/__tests__/httpCache.test.ts rename to packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts index 759211c36c4..c91ce9528ad 100644 --- a/packages/apollo-server-caching/src/__tests__/httpCache.test.ts +++ b/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts @@ -1,4 +1,5 @@ -import { HTTPCache } from '../httpCache'; +import 'apollo-server-env'; +import { HTTPCache } from '../HTTPCache'; import fetch, { mockFetch, unmockFetch } from '../__mocks__/fetch'; import { mockDate, unmockDate, advanceTimeBy } from '../__mocks__/date'; 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 00000000000..63a762bd529 --- /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 00000000000..c9562bc62a7 --- /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-server-caching/src/types/http-cache-semantics/index.d.ts b/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts similarity index 100% rename from packages/apollo-server-caching/src/types/http-cache-semantics/index.d.ts rename to packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts diff --git a/packages/apollo-server-caching/tsconfig.json b/packages/apollo-datasource-rest/tsconfig.json similarity index 89% rename from packages/apollo-server-caching/tsconfig.json rename to packages/apollo-datasource-rest/tsconfig.json index 9c00a07cfcc..3cc3ec7661a 100644 --- a/packages/apollo-server-caching/tsconfig.json +++ b/packages/apollo-datasource-rest/tsconfig.json @@ -12,5 +12,5 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"], - "types": ["node", "fetch", "url", "jest"] + "types": ["node", "jest"] } diff --git a/packages/apollo-server-caching/src/polyfills/fetch.ts b/packages/apollo-server-caching/src/polyfills/fetch.ts deleted file mode 100644 index 2a374195110..00000000000 --- a/packages/apollo-server-caching/src/polyfills/fetch.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare namespace NodeJS { - interface Global { - fetch: typeof fetch; - Request: typeof Request; - Response: typeof Response; - Headers: typeof Headers; - } -} - -if (!global.fetch) { - const { default: fetch, Request, Response, Headers } = require('node-fetch'); - - global.fetch = fetch; - global.Request = Request; - global.Response = Response; - global.Headers = Headers; -} diff --git a/packages/apollo-server-caching/src/polyfills/url.ts b/packages/apollo-server-caching/src/polyfills/url.ts deleted file mode 100644 index d3a582ea9ca..00000000000 --- a/packages/apollo-server-caching/src/polyfills/url.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare namespace NodeJS { - interface Global { - URL: typeof URL; - URLSearchParams: typeof URLSearchParams; - } -} - -if (!global.URL) { - const { URL, URLSearchParams } = require('url'); - - global.URL = URL; - global.URLSearchParams = URLSearchParams; -} diff --git a/packages/apollo-server-cloudflare/tsconfig.json b/packages/apollo-server-cloudflare/tsconfig.json index 8aa9ee6acf6..d8d6df99369 100644 --- a/packages/apollo-server-cloudflare/tsconfig.json +++ b/packages/apollo-server-cloudflare/tsconfig.json @@ -6,5 +6,5 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "types": ["fetch", "url"] + "types": [] } diff --git a/packages/apollo-server-core/package.json b/packages/apollo-server-core/package.json index e9e212ada5b..336bb87ad24 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/graphqlOptions.ts b/packages/apollo-server-core/src/graphqlOptions.ts index 9333504dac6..a05f60ca169 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 } from 'apollo-datasource-rest'; /* * GraphQLServerOptions @@ -42,9 +43,12 @@ export interface GraphQLServerOptions< // cacheControl?: boolean | CacheControlExtensionOptions; cacheControl?: boolean | any; extensions?: Array<() => GraphQLExtension>; + dataSources?: () => DataSources; persistedQueries?: PersistedQueryOptions; } +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 229ec58dba1..a363dc6305b 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 5aa123a0a2a..00e3dccfdac 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,25 @@ export async function runHttpQuery( ); } + if (optionsObject.dataSources) { + const dataSources = optionsObject.dataSources(); + + const httpCache = new HTTPCache(); + + for (const dataSource of Object.values(dataSources)) { + dataSource.httpCache = httpCache; + dataSource.context = context; + } + + 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 c4170680238..4597617d133 100644 --- a/packages/apollo-server-core/src/types.ts +++ b/packages/apollo-server-core/src/types.ts @@ -42,6 +42,7 @@ export interface Config | 'fieldResolver' | 'cacheControl' | 'tracing' + | 'dataSources' > { typeDefs?: DocumentNode | [DocumentNode]; resolvers?: IResolvers; diff --git a/packages/apollo-server-env/.npmignore b/packages/apollo-server-env/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /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 00000000000..9ef523087e6 --- /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", + "prepare": "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-caching/src/index.ts b/packages/apollo-server-env/src/index.ts similarity index 56% rename from packages/apollo-server-caching/src/index.ts rename to packages/apollo-server-env/src/index.ts index c11deb05a46..61cdc0118d9 100644 --- a/packages/apollo-server-caching/src/index.ts +++ b/packages/apollo-server-env/src/index.ts @@ -1,4 +1,3 @@ +import './polyfills/Object.values'; import './polyfills/fetch'; import './polyfills/url'; - -export { HTTPCache } from './httpCache'; 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 00000000000..70ec049a09f --- /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() { + return Object.keys(this).map(key => this[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 00000000000..262ea95973c --- /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 00000000000..c0336e667d3 --- /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 00000000000..f37e6cb5e77 --- /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/tsconfig.json b/tsconfig.json index 14e028f394e..9b48689468b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,6 @@ "noUnusedLocals": true, "noUnusedParameters": true, "lib": ["es2016", "esnext.asynciterable"], - "types": ["node", "fetch", "url", "mocha"], - "typeRoots": ["./node_modules/@types", "./types"] + "types": ["node", "mocha"] } } diff --git a/types/fetch/index.d.ts b/types/fetch/index.d.ts deleted file mode 100644 index 931253ef865..00000000000 --- a/types/fetch/index.d.ts +++ /dev/null @@ -1,111 +0,0 @@ -declare function fetch( - input?: RequestInfo, - init?: RequestInit, -): Promise; - -interface GlobalFetch { - fetch: typeof fetch; -} - -type RequestInfo = Request | string; - -declare 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 }; - -declare class Body { - readonly bodyUsed: boolean; - arrayBuffer(): Promise; - json(): Promise; - text(): Promise; -} - -declare 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'; - -declare 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; - -declare class Blob { - type: string; - size: number; - slice(start?: number, end?: number): Blob; -} diff --git a/types/url/index.d.ts b/types/url/index.d.ts deleted file mode 100644 index 13945f686ac..00000000000 --- a/types/url/index.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -declare 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; -} - -declare 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]>; -} From dbd8288d4214c359bdefdc015f5e3afc460ec8b7 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Thu, 14 Jun 2018 20:21:22 +0200 Subject: [PATCH 06/14] Fix broken imports --- packages/apollo-datasource-rest/src/HTTPCache.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/apollo-datasource-rest/src/HTTPCache.ts b/packages/apollo-datasource-rest/src/HTTPCache.ts index bef300b7ca2..196d8f0d3c0 100644 --- a/packages/apollo-datasource-rest/src/HTTPCache.ts +++ b/packages/apollo-datasource-rest/src/HTTPCache.ts @@ -1,6 +1,7 @@ import CachePolicy from 'http-cache-semantics'; -import { KeyValueCache, InMemoryKeyValueCache } from './keyValueCaching'; +import { KeyValueCache } from './KeyValueCache'; +import { InMemoryKeyValueCache } from './InMemoryKeyValueCache'; export class HTTPCache { constructor( From eda8ce9fb84610250acdad42322cf822aa0666e4 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Thu, 14 Jun 2018 20:56:14 +0200 Subject: [PATCH 07/14] Use prepublish instead of prepare --- packages/apollo-datasource-rest/package.json | 2 +- packages/apollo-server-env/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apollo-datasource-rest/package.json b/packages/apollo-datasource-rest/package.json index 03ecea4c497..b09edd1ae4a 100644 --- a/packages/apollo-datasource-rest/package.json +++ b/packages/apollo-datasource-rest/package.json @@ -14,7 +14,7 @@ "scripts": { "clean": "rm -rf lib", "compile": "tsc", - "prepare": "npm run clean && npm run compile", + "prepublish": "npm run clean && npm run compile", "test": "jest --verbose" }, "main": "dist/index.js", diff --git a/packages/apollo-server-env/package.json b/packages/apollo-server-env/package.json index 9ef523087e6..939d3953fe6 100644 --- a/packages/apollo-server-env/package.json +++ b/packages/apollo-server-env/package.json @@ -14,7 +14,7 @@ "scripts": { "clean": "rm -rf lib", "compile": "tsc", - "prepare": "npm run clean && npm run compile" + "prepublish": "npm run clean && npm run compile" }, "main": "dist/index.js", "types": "dist/index.d.ts", From e6aae1d41b6bd03f631e6699776558ec594b8f7f Mon Sep 17 00:00:00 2001 From: Evans Hauser Date: Thu, 14 Jun 2018 17:42:32 -0700 Subject: [PATCH 08/14] cache is now passed to data sources from ApolloServer constructor --- packages/apollo-datasource-rest/src/RESTDataSource.ts | 10 +++++++++- packages/apollo-server-core/src/ApolloServer.ts | 5 +++++ packages/apollo-server-core/src/graphqlOptions.ts | 5 +++-- packages/apollo-server-core/src/runHttpQuery.ts | 9 +++++---- packages/apollo-server-core/src/types.ts | 3 +++ 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts index 45fe9b91c46..46b803ef7c0 100644 --- a/packages/apollo-datasource-rest/src/RESTDataSource.ts +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -8,7 +8,15 @@ export abstract class RESTDataSource { httpCache!: HTTPCache; context!: TContext; - willSendRequest?(request: Request): void; + public willSendRequest?(request: Request): void; + + public willReceiveCache(httpCache: HTTPCache) { + this.httpCache = httpCache; + } + + public willReceiveContext(context: TContext) { + this.context = context; + } protected async get( path: string, diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index c84d925e857..a7a0aa223ba 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 a05f60ca169..60716c1338f 100644 --- a/packages/apollo-server-core/src/graphqlOptions.ts +++ b/packages/apollo-server-core/src/graphqlOptions.ts @@ -6,7 +6,7 @@ import { import { LogFunction } from './logging'; import { PersistedQueryCache } from './caching'; import { GraphQLExtension } from 'graphql-extensions'; -import { RESTDataSource } from 'apollo-datasource-rest'; +import { RESTDataSource, KeyValueCache } from 'apollo-datasource-rest'; /* * GraphQLServerOptions @@ -44,10 +44,11 @@ export interface GraphQLServerOptions< cacheControl?: boolean | any; extensions?: Array<() => GraphQLExtension>; dataSources?: () => DataSources; + cache?: KeyValueCache; persistedQueries?: PersistedQueryOptions; } -type DataSources = { [name: string]: RESTDataSource }; +export type DataSources = { [name: string]: RESTDataSource }; export interface PersistedQueryOptions { cache: PersistedQueryCache; diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index 00e3dccfdac..d48aae60607 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -286,13 +286,14 @@ export async function runHttpQuery( } if (optionsObject.dataSources) { - const dataSources = optionsObject.dataSources(); + const dataSources = optionsObject.dataSources() || {}; - const httpCache = new HTTPCache(); + //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.httpCache = httpCache; - dataSource.context = context; + dataSource.willReceiveContext(context); + dataSource.willReceiveCache(httpCache); } if ('dataSources' in context) { diff --git a/packages/apollo-server-core/src/types.ts b/packages/apollo-server-core/src/types.ts index fcaa3e2af10..42b2c44acf9 100644 --- a/packages/apollo-server-core/src/types.ts +++ b/packages/apollo-server-core/src/types.ts @@ -11,6 +11,8 @@ import { PersistedQueryOptions, } from './graphqlOptions'; +export { KeyValueCache } from 'apollo-datasource-rest'; + export type Context = T; export type ContextFunction = ( context: Context, @@ -41,6 +43,7 @@ export interface Config | 'cacheControl' | 'tracing' | 'dataSources' + | 'cache' > { typeDefs?: DocumentNode | [DocumentNode]; resolvers?: IResolvers; From 29d5998a67d9292eb4f4ebd9cbf7c06da9a290a8 Mon Sep 17 00:00:00 2001 From: Evans Hauser Date: Thu, 14 Jun 2018 17:43:13 -0700 Subject: [PATCH 09/14] fix Object.values to use the object passed in rather than this --- packages/apollo-server-env/src/polyfills/Object.values.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apollo-server-env/src/polyfills/Object.values.ts b/packages/apollo-server-env/src/polyfills/Object.values.ts index 70ec049a09f..6f3409b2e7e 100644 --- a/packages/apollo-server-env/src/polyfills/Object.values.ts +++ b/packages/apollo-server-env/src/polyfills/Object.values.ts @@ -7,7 +7,7 @@ interface ObjectConstructor { } if (!global.Object.values) { - global.Object.values = function() { - return Object.keys(this).map(key => this[key]); + global.Object.values = function(o) { + return Object.keys(o).map(key => o[key]); }; } From 7b2d694400a83922c7dc9871ddcabdfd80562aae Mon Sep 17 00:00:00 2001 From: Evans Hauser Date: Thu, 14 Jun 2018 17:44:35 -0700 Subject: [PATCH 10/14] add initial datasource test --- packages/apollo-server-express/package.json | 1 + .../src/datasource.test.ts | 157 ++++++++++++++++++ test/tests.js | 1 + 3 files changed, 159 insertions(+) create mode 100644 packages/apollo-server-express/src/datasource.test.ts diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index e132d9c5e6c..ab589af92ec 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 00000000000..e155cd3dbb8 --- /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 cda46907b18..8eb5f84771a 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)) && From b39db20d99f8c55e7a93346f0d16a79d8abaf121 Mon Sep 17 00:00:00 2001 From: Evans Hauser Date: Thu, 14 Jun 2018 20:15:03 -0700 Subject: [PATCH 11/14] docs: initial data source documentation --- docs/_config.yml | 1 + docs/source/features/data-sources.md | 78 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 docs/source/features/data-sources.md diff --git a/docs/_config.yml b/docs/_config.yml index 12810dfc207..850dba55aa7 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 00000000000..f686a8b10e6 --- /dev/null +++ b/docs/source/features/data-sources.md @@ -0,0 +1,78 @@ +--- +title: Data Sources +description: Caching Partial Query Results +--- + +Data Sources are an extension of data connector that can be extended to create data models. At their core, data sources include functions that can access a type of data source and build in the best practices for caching, deduplication, and batching. By containing the functionality to fetch from a backend, data sources allow server implementation to focus on the core of the project or business, naming the functions that access data and routing where that data comes from. With data sources solving how data is accessed, the server implementation can focus on what and where data is accessed. + +The first data source provides this functionality for REST sources. + +## REST Data Source + +A RESTDataSource encapsulates access to a particular REST data source. It contains data source specific configuration and relies on convenience methods to perform HTTP requests with built-in support for caching, batching, error handling, and tracing. First we define a data model that extends the RESTDataSource. This code snippet demonstrates a data model for a jokes api. + +```js +export class JokesAPI extends RESTDataSource { + baseURL = 'https://api.icndb.com'; + + async getJoke(id: string) { + return this.get(`jokes/random/${id}`); + } + + async getJokeByPerson(firstName: string, lastName: string) { + const body = await this.get('jokes/random', { + firstName, + lastName, + }); + return body.results; + } +} +``` + +To create a data source, we provide them to the ApolloServer constructor + +```js +const server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + jokes: new JokesApi(), + }), +}); +``` + +Then in our resolvers, we can access the data source and return the result. If this request is fetched multiple times during the same request, it will be deduplicated and send a single http request. Provided that the `cache-control` headers are set correctly by the REST api, then the result will be cached across requests for the time specified by the backend. + +```js +const typeDefs = gql` +type Query { + joke: Joke +} + +type Joke { + id: ID + joke: String +} +` + +const resolvers = { + Query: { + joke: (_,_,{ dataSources }) => { + return dataSources.jokes.getJoke.then(({ value }) => value) + } + } +} + +``` + +The raw response from the joke REST api appears as follows: + +```js +{ + "type": "success", + "value": { + "id": 268, + "joke": "Time waits for no man. Unless that man is Chuck Noris." + } +} +``` From 93d21f898260c77b1bceec2a0135123dfcd757ec Mon Sep 17 00:00:00 2001 From: Evans Hauser Date: Thu, 14 Jun 2018 21:08:45 -0700 Subject: [PATCH 12/14] docs: initial data source documentation --- docs/source/features/data-sources.md | 41 ++++++++----------- .../src/RESTDataSource.ts | 2 +- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/docs/source/features/data-sources.md b/docs/source/features/data-sources.md index f686a8b10e6..7ad66a45eec 100644 --- a/docs/source/features/data-sources.md +++ b/docs/source/features/data-sources.md @@ -5,26 +5,24 @@ description: Caching Partial Query Results Data Sources are an extension of data connector that can be extended to create data models. At their core, data sources include functions that can access a type of data source and build in the best practices for caching, deduplication, and batching. By containing the functionality to fetch from a backend, data sources allow server implementation to focus on the core of the project or business, naming the functions that access data and routing where that data comes from. With data sources solving how data is accessed, the server implementation can focus on what and where data is accessed. -The first data source provides this functionality for REST sources. +Currently the first data source is designed for REST and provides caching for http requests that contain cache control information in the headers. In future releases, this functionality will be expanded to include error handling with automatic retries, enhanced tracing, and batching. ## REST Data Source -A RESTDataSource encapsulates access to a particular REST data source. It contains data source specific configuration and relies on convenience methods to perform HTTP requests with built-in support for caching, batching, error handling, and tracing. First we define a data model that extends the RESTDataSource. This code snippet demonstrates a data model for a jokes api. +A RESTDataSource encapsulates access to a particular REST data source. It contains data source specific configuration and relies on convenience methods to perform HTTP requests with built-in support for caching, batching, error handling, and tracing. First we define a data model that extends the RESTDataSource. This code snippet demonstrates a data model for the star wars api, which supports entity tags, which means that the data source will cache the data automatically. ```js -export class JokesAPI extends RESTDataSource { - baseURL = 'https://api.icndb.com'; +export class StarWarsAPI extends RESTDataSource { + baseURL = 'https://swapi.co/api/'; - async getJoke(id: string) { - return this.get(`jokes/random/${id}`); + async getPerson(id: string) { + return this.get(`people/${id}`); } - async getJokeByPerson(firstName: string, lastName: string) { - const body = await this.get('jokes/random', { - firstName, - lastName, + async searchPerson(search: string) { + return this.get(`people/`, { + search, }); - return body.results; } } ``` @@ -36,7 +34,7 @@ const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ - jokes: new JokesApi(), + StarWars: new StarWarsAPI(), }), }); ``` @@ -46,33 +44,28 @@ Then in our resolvers, we can access the data source and return the result. If t ```js const typeDefs = gql` type Query { - joke: Joke + person: Person } -type Joke { - id: ID - joke: String +type Person { + name: String } ` const resolvers = { Query: { - joke: (_,_,{ dataSources }) => { - return dataSources.jokes.getJoke.then(({ value }) => value) + person: (_,_,{ dataSources }) => { + return dataSources.StarWars.getPerson('1') } } } ``` -The raw response from the joke REST api appears as follows: +The raw response from the Star Wars REST api appears as follows: ```js { - "type": "success", - "value": { - "id": 268, - "joke": "Time waits for no man. Unless that man is Chuck Noris." - } + "name": "Luke Skywalker", } ``` diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts index 46b803ef7c0..57f0a32ff57 100644 --- a/packages/apollo-datasource-rest/src/RESTDataSource.ts +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -93,7 +93,7 @@ export abstract class RESTDataSource { return await fn(); } finally { const duration = Date.now() - startTime; - console.log(`${label} (${duration}ms)`); + // console.log(`${label} (${duration}ms)`); } } } From 654819605974f7c6637b720c30e07d4732db2eec Mon Sep 17 00:00:00 2001 From: Evans Hauser Date: Thu, 14 Jun 2018 22:10:06 -0700 Subject: [PATCH 13/14] compiles and documentation now highlights code in data-sources.md --- docs/source/features/data-sources.md | 4 ++-- packages/apollo-datasource-rest/src/RESTDataSource.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/source/features/data-sources.md b/docs/source/features/data-sources.md index 7ad66a45eec..abfdc905071 100644 --- a/docs/source/features/data-sources.md +++ b/docs/source/features/data-sources.md @@ -9,7 +9,7 @@ Currently the first data source is designed for REST and provides caching for ht ## REST Data Source -A RESTDataSource encapsulates access to a particular REST data source. It contains data source specific configuration and relies on convenience methods to perform HTTP requests with built-in support for caching, batching, error handling, and tracing. First we define a data model that extends the RESTDataSource. This code snippet demonstrates a data model for the star wars api, which supports entity tags, which means that the data source will cache the data automatically. +A `RESTDataSource` encapsulates access to a particular REST data source. It contains data source specific configuration and relies on convenience methods to perform HTTP requests with built-in support for caching, batching, error handling, and tracing. First we define a data model that extends the `RESTDataSource`. This code snippet demonstrates a data model for the star wars api, which supports entity tags, which means that the data source will cache the data automatically. ```js export class StarWarsAPI extends RESTDataSource { @@ -27,7 +27,7 @@ export class StarWarsAPI extends RESTDataSource { } ``` -To create a data source, we provide them to the ApolloServer constructor +To create a data source, we provide them to the `ApolloServer` constructor ```js const server = new ApolloServer({ diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts index 57f0a32ff57..573647851d8 100644 --- a/packages/apollo-datasource-rest/src/RESTDataSource.ts +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -93,6 +93,9 @@ export abstract class RESTDataSource { return await fn(); } finally { const duration = Date.now() - startTime; + //to remove the unused error + label; + duration; // console.log(`${label} (${duration}ms)`); } } From 1703e27e0a18e4e41dbd46a549a0940f2cedc99e Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Fri, 15 Jun 2018 07:55:07 +0200 Subject: [PATCH 14/14] Some changes to the data source docs --- docs/source/features/data-sources.md | 41 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/source/features/data-sources.md b/docs/source/features/data-sources.md index abfdc905071..2aac7fb60c7 100644 --- a/docs/source/features/data-sources.md +++ b/docs/source/features/data-sources.md @@ -3,13 +3,13 @@ title: Data Sources description: Caching Partial Query Results --- -Data Sources are an extension of data connector that can be extended to create data models. At their core, data sources include functions that can access a type of data source and build in the best practices for caching, deduplication, and batching. By containing the functionality to fetch from a backend, data sources allow server implementation to focus on the core of the project or business, naming the functions that access data and routing where that data comes from. With data sources solving how data is accessed, the server implementation can focus on what and where data is accessed. - -Currently the first data source is designed for REST and provides caching for http requests that contain cache control information in the headers. In future releases, this functionality will be expanded to include error handling with automatic retries, enhanced tracing, and batching. +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` encapsulates access to a particular REST data source. It contains data source specific configuration and relies on convenience methods to perform HTTP requests with built-in support for caching, batching, error handling, and tracing. First we define a data model that extends the `RESTDataSource`. This code snippet demonstrates a data model for the star wars api, which supports entity tags, which means that the data source will cache the data automatically. +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 { @@ -34,35 +34,36 @@ const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ - StarWars: new StarWarsAPI(), + starWars: new StarWarsAPI(), }), }); ``` -Then in our resolvers, we can access the data source and return the result. If this request is fetched multiple times during the same request, it will be deduplicated and send a single http request. Provided that the `cache-control` headers are set correctly by the REST api, then the result will be cached across requests for the time specified by the backend. +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 Query { + person: Person + } -type Person { - name: String -} -` + type Person { + name: String + } +`; const resolvers = { Query: { - person: (_,_,{ dataSources }) => { - return dataSources.StarWars.getPerson('1') - } - } -} - + person: (_, id, { dataSources }) => { + return dataSources.starWars.getPerson(id); + }, + }, +}; ``` -The raw response from the Star Wars REST api appears as follows: +The raw response from the Star Wars REST API appears as follows: ```js {