From c9e2f6f26ab5b8cf6f8bdcf9d4633a75758f9e92 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Wed, 16 Jan 2019 12:42:07 -0800 Subject: [PATCH] Full query response cache plugin - A new plugin package implementing a response cache, based on apollo-cache-control hints. - GraphQLRequestContext new fields: - overallCachePolicy - documentText - metrics - New plugin hook responseForOperation. - new GraphQLExtension hook didResolveOperation, identical to the same hook in the Plugin API. Change apollo-engine-reporting to use this hook instead of executionDidStart, because executionDidStart doesn't run if the cache short-circuits execution. - apollo-engine-reporting: report whether the request was a cache hit. Also use the new requestContext.metrics object to report persisted query hit/register instead of specific extension options (though those extension options still work). - cacheControl constructor option semantic change: include the cacheControl GraphQL extension in the output with `cacheControl: true` and `cacheControl: {stripFormattedExtensions: true}` (as before), but not for `cacheControl: {otherOptions: ...}`. --- package-lock.json | 9 + package.json | 1 + .../src/__tests__/collectCacheControlHints.ts | 7 +- packages/apollo-cache-control/src/index.ts | 25 +- .../apollo-engine-reporting/src/extension.ts | 39 +- .../src/KeyValueCache.ts | 1 + .../apollo-server-core/src/requestPipeline.ts | 68 ++-- .../src/requestPipelineAPI.ts | 13 + .../src/utils/dispatcher.ts | 19 + .../src/ApolloServer.ts | 279 +++++++++++++++ .../apollo-server-plugin-base/src/index.ts | 11 + .../.npmignore | 6 + .../CHANGELOG.md | 4 + .../README.md | 12 + .../jest.config.js | 3 + .../package.json | 30 ++ .../src/ApolloServerPluginResponseCache.ts | 337 ++++++++++++++++++ .../ApolloServerPluginResponseCache.test.ts | 7 + .../src/__tests__/tsconfig.json | 7 + .../src/index.ts | 3 + .../tsconfig.json | 14 + packages/graphql-extensions/src/index.ts | 14 + tsconfig.build.json | 1 + tsconfig.test.json | 1 + 24 files changed, 862 insertions(+), 49 deletions(-) create mode 100644 packages/apollo-server-plugin-response-cache/.npmignore create mode 100644 packages/apollo-server-plugin-response-cache/CHANGELOG.md create mode 100644 packages/apollo-server-plugin-response-cache/README.md create mode 100644 packages/apollo-server-plugin-response-cache/jest.config.js create mode 100644 packages/apollo-server-plugin-response-cache/package.json create mode 100644 packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts create mode 100644 packages/apollo-server-plugin-response-cache/src/__tests__/ApolloServerPluginResponseCache.test.ts create mode 100644 packages/apollo-server-plugin-response-cache/src/__tests__/tsconfig.json create mode 100644 packages/apollo-server-plugin-response-cache/src/index.ts create mode 100644 packages/apollo-server-plugin-response-cache/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 7c94bd2f810..6f80599b61a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2368,6 +2368,15 @@ "apollo-server-plugin-base": { "version": "file:packages/apollo-server-plugin-base" }, + "apollo-server-plugin-response-cache": { + "version": "file:packages/apollo-server-plugin-response-cache", + "requires": { + "apollo-cache-control": "file:packages/apollo-cache-control", + "apollo-server-caching": "file:packages/apollo-server-caching", + "apollo-server-env": "file:packages/apollo-server-env", + "apollo-server-plugin-base": "file:packages/apollo-server-plugin-base" + } + }, "apollo-server-testing": { "version": "file:packages/apollo-server-testing", "requires": { diff --git a/package.json b/package.json index 2fedfc1ed14..4940c226294 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "apollo-server-lambda": "file:packages/apollo-server-lambda", "apollo-server-micro": "file:packages/apollo-server-micro", "apollo-server-plugin-base": "file:packages/apollo-server-plugin-base", + "apollo-server-plugin-response-cache": "file:packages/apollo-server-plugin-response-cache", "apollo-server-testing": "file:packages/apollo-server-testing", "apollo-tracing": "file:packages/apollo-tracing", "graphql-extensions": "file:packages/graphql-extensions" diff --git a/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts b/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts index 1730e5c32c1..e69e8bc448e 100644 --- a/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts +++ b/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts @@ -17,7 +17,12 @@ export async function collectCacheControlHints( ): Promise { enableGraphQLExtensions(schema); - const cacheControlExtension = new CacheControlExtension(options); + // Because this test helper looks at the formatted extensions, we always want + // to include them. + const cacheControlExtension = new CacheControlExtension({ + ...options, + stripFormattedExtensions: false, + }); const response = await graphql({ schema, diff --git a/packages/apollo-cache-control/src/index.ts b/packages/apollo-cache-control/src/index.ts index 0165628f106..e8e69fac600 100644 --- a/packages/apollo-cache-control/src/index.ts +++ b/packages/apollo-cache-control/src/index.ts @@ -42,6 +42,13 @@ declare module 'graphql/type/definition' { } } +declare module 'apollo-server-core/dist/requestPipelineAPI' { + interface GraphQLRequestContext { + // Not readonly: plugins can set it. + overallCachePolicy?: Required | undefined; + } +} + export class CacheControlExtension implements GraphQLExtension { private defaultMaxAge: number; @@ -51,6 +58,7 @@ export class CacheControlExtension } private hints: Map = new Map(); + private overallCachePolicyOverride?: Required; willResolveField( _source: any, @@ -123,7 +131,14 @@ export class CacheControlExtension } format(): [string, CacheControlFormat] | undefined { - if (this.options.stripFormattedExtensions) return; + // We should have to explicitly ask leave the formatted extension in, or + // pass the old-school `cacheControl: true` (as interpreted by + // apollo-server-core/ApolloServer), in order to include the + // engineproxy-aimed extensions. Specifically, we want users of + // apollo-server-plugin-response-cache to be able to specify + // `cacheControl: {defaultMaxAge: 600}` without accidentally turning on the + // extension formatting. + if (this.options.stripFormattedExtensions !== false) return; return [ 'cacheControl', @@ -152,7 +167,15 @@ export class CacheControlExtension } } + public overrideOverallCachePolicy(overallCachePolicy: Required) { + this.overallCachePolicyOverride = overallCachePolicy; + } + computeOverallCachePolicy(): Required | undefined { + if (this.overallCachePolicyOverride) { + return this.overallCachePolicyOverride; + } + let lowestMaxAge: number | undefined = undefined; let scope: CacheScope = CacheScope.Public; diff --git a/packages/apollo-engine-reporting/src/extension.ts b/packages/apollo-engine-reporting/src/extension.ts index bbc31a26860..b1fa5200f3e 100644 --- a/packages/apollo-engine-reporting/src/extension.ts +++ b/packages/apollo-engine-reporting/src/extension.ts @@ -5,7 +5,6 @@ import { responsePathAsArray, ResponsePath, DocumentNode, - ExecutionArgs, GraphQLError, } from 'graphql'; import { @@ -34,7 +33,7 @@ export class EngineReportingExtension public trace = new Trace(); private nodes = new Map(); private startHrTime!: [number, number]; - private operationName?: string; + private operationName?: string | null; private queryString?: string; private documentAST?: DocumentNode; private options: EngineReportingOptions; @@ -92,8 +91,6 @@ export class EngineReportingExtension queryString?: string; parsedQuery?: DocumentNode; variables?: Record; - persistedQueryHit?: boolean; - persistedQueryRegister?: boolean; context: TContext; extensions?: Record; requestContext: GraphQLRequestContext; @@ -149,10 +146,10 @@ export class EngineReportingExtension } } - if (o.persistedQueryHit) { + if (o.requestContext.metrics!.persistedQueryHit) { this.trace.persistedQueryHit = true; } - if (o.persistedQueryRegister) { + if (o.requestContext.metrics!.persistedQueryRegister) { this.trace.persistedQueryRegister = true; } } @@ -213,6 +210,9 @@ export class EngineReportingExtension ); this.trace.endTime = dateToTimestamp(new Date()); + this.trace.fullQueryCacheHit = !!o.requestContext.metrics! + .responseCacheHit; + const operationName = this.operationName || ''; let signature; if (this.documentAST) { @@ -237,21 +237,13 @@ export class EngineReportingExtension }; } - public executionDidStart(o: { executionArgs: ExecutionArgs }) { - // If the operationName is explicitly provided, save it. If there's just one - // named operation, the client doesn't have to provide it, but we still want - // to know the operation name so that the server can identify the query by - // it without having to parse a signature. - // - // Fortunately, in the non-error case, we can just pull this out of - // the first call to willResolveField's `info` argument. In an - // error case (eg, the operationName isn't found, or there are more - // than one operation and no specified operationName) it's OK to continue - // to file this trace under the empty operationName. - if (o.executionArgs.operationName) { - this.operationName = o.executionArgs.operationName; - } - this.documentAST = o.executionArgs.document; + public didResolveOperation(o: { + requestContext: GraphQLRequestContext; + }) { + const { requestContext } = o; + + this.operationName = requestContext.operationName; + this.documentAST = requestContext.document; } public willResolveField( @@ -260,11 +252,6 @@ export class EngineReportingExtension _context: TContext, info: GraphQLResolveInfo, ): ((error: Error | null, result: any) => void) | void { - if (this.operationName === undefined) { - this.operationName = - (info.operation.name && info.operation.name.value) || ''; - } - const path = info.path; const node = this.newNode(path); node.type = info.returnType.toString(); diff --git a/packages/apollo-server-caching/src/KeyValueCache.ts b/packages/apollo-server-caching/src/KeyValueCache.ts index a983a90679b..64b298cd0c0 100644 --- a/packages/apollo-server-caching/src/KeyValueCache.ts +++ b/packages/apollo-server-caching/src/KeyValueCache.ts @@ -1,5 +1,6 @@ export interface KeyValueCache { get(key: string): Promise; + // ttl is measured in seconds. set(key: string, value: V, options?: { ttl?: number }): Promise; delete(key: string): Promise; } diff --git a/packages/apollo-server-core/src/requestPipeline.ts b/packages/apollo-server-core/src/requestPipeline.ts index f1bde17455c..14a8c468d1b 100644 --- a/packages/apollo-server-core/src/requestPipeline.ts +++ b/packages/apollo-server-core/src/requestPipeline.ts @@ -109,6 +109,10 @@ export async function processGraphQLRequest( initializeDataSources(); + if (!requestContext.metrics) { + requestContext.metrics = {}; + } + const request = requestContext.request; let { query, extensions } = request; @@ -116,8 +120,8 @@ export async function processGraphQLRequest( let queryHash: string; let persistedQueryCache: KeyValueCache | undefined; - let persistedQueryHit = false; - let persistedQueryRegister = false; + requestContext.metrics.persistedQueryHit = false; + requestContext.metrics.persistedQueryRegister = false; if (extensions && extensions.persistedQuery) { // It looks like we've received a persisted query. Check if we @@ -150,7 +154,7 @@ export async function processGraphQLRequest( if (query === undefined) { query = await persistedQueryCache.get(queryHash); if (query) { - persistedQueryHit = true; + requestContext.metrics.persistedQueryHit = true; } else { throw new PersistedQueryNotFoundError(); } @@ -167,7 +171,7 @@ export async function processGraphQLRequest( // Defering the writing gives plugins the ability to "win" from use of // the cache, but also have their say in whether or not the cache is // written to (by interrupting the request with an error). - persistedQueryRegister = true; + requestContext.metrics.persistedQueryRegister = true; } } else if (query) { // FIXME: We'll compute the APQ query hash to use as our cache key for @@ -178,6 +182,7 @@ export async function processGraphQLRequest( } requestContext.queryHash = queryHash; + requestContext.documentText = query; const requestDidEnd = extensionStack.requestDidStart({ request: request.http!, @@ -185,9 +190,9 @@ export async function processGraphQLRequest( operationName: request.operationName, variables: request.variables, extensions: request.extensions, - persistedQueryHit, - persistedQueryRegister, context: requestContext.context, + persistedQueryHit: requestContext.metrics.persistedQueryHit, + persistedQueryRegister: requestContext.metrics.persistedQueryRegister, requestContext, }); @@ -284,32 +289,53 @@ export async function processGraphQLRequest( // pipeline, and given plugins appropriate ability to object (by throwing // an error) and not actually write, we'll write to the cache if it was // determined earlier in the request pipeline that we should do so. - if (persistedQueryRegister && persistedQueryCache) { + if (requestContext.metrics.persistedQueryRegister && persistedQueryCache) { Promise.resolve(persistedQueryCache.set(queryHash, query)).catch( console.warn, ); } - const executionDidEnd = await dispatcher.invokeDidStartHook( - 'executionDidStart', + let response: GraphQLResponse | null = await dispatcher.invokeHooksUntilNonNull( + 'responseForOperation', requestContext as WithRequired< typeof requestContext, 'document' | 'operation' | 'operationName' >, ); + if (response == null) { + const executionDidEnd = await dispatcher.invokeDidStartHook( + 'executionDidStart', + requestContext as WithRequired< + typeof requestContext, + 'document' | 'operation' | 'operationName' + >, + ); - let response: GraphQLResponse; + try { + response = (await execute( + requestContext.document, + request.operationName, + request.variables, + )) as GraphQLResponse; + executionDidEnd(); + } catch (executionError) { + executionDidEnd(executionError); + return sendErrorResponse(executionError); + } + } - try { - response = (await execute( - requestContext.document, - request.operationName, - request.variables, - )) as GraphQLResponse; - executionDidEnd(); - } catch (executionError) { - executionDidEnd(executionError); - return sendErrorResponse(executionError); + if (cacheControlExtension) { + if (requestContext.overallCachePolicy) { + // If we read this response from a cache and it already has its own + // policy, teach that to cacheControlExtension so that it'll use the + // saved policy for HTTP headers. (If cacheControlExtension was a + // plugin, it could just read from the requestContext, but it isn't.) + cacheControlExtension.overrideOverallCachePolicy( + requestContext.overallCachePolicy, + ); + } else { + requestContext.overallCachePolicy = cacheControlExtension.computeOverallCachePolicy(); + } } const formattedExtensions = extensionStack.format(); @@ -323,7 +349,7 @@ export async function processGraphQLRequest( }); } - return sendResponse(response); + return sendResponse(response!!); } finally { requestDidEnd(); } diff --git a/packages/apollo-server-core/src/requestPipelineAPI.ts b/packages/apollo-server-core/src/requestPipelineAPI.ts index a4d00fc8999..0aa001dcd2c 100644 --- a/packages/apollo-server-core/src/requestPipelineAPI.ts +++ b/packages/apollo-server-core/src/requestPipelineAPI.ts @@ -39,6 +39,16 @@ export interface GraphQLResponse { http?: Pick; } +export interface GraphQLRequestMetrics { + persistedQueryHit?: boolean; + persistedQueryRegister?: boolean; + // XXX I thought about making this an augmentation either from + // apollo-engine-reporting or apollo-server-plugin-response-cache but that + // seemed to mean that one of those packages would have to depend on the + // other, which seemed wrong. Happy to hear there's a better way. + responseCacheHit?: boolean; +} + export interface GraphQLRequestContext> { readonly request: GraphQLRequest; readonly response?: GraphQLResponse; @@ -50,6 +60,7 @@ export interface GraphQLRequestContext> { readonly queryHash?: string; readonly document?: DocumentNode; + readonly documentText?: string; // `operationName` is set based on the operation AST, so it is defined // even if no `request.operationName` was passed in. @@ -57,6 +68,8 @@ export interface GraphQLRequestContext> { readonly operationName?: string | null; readonly operation?: OperationDefinitionNode; + readonly metrics?: GraphQLRequestMetrics; + debug?: boolean; } diff --git a/packages/apollo-server-core/src/utils/dispatcher.ts b/packages/apollo-server-core/src/utils/dispatcher.ts index 4c787761301..917a99f1f3c 100644 --- a/packages/apollo-server-core/src/utils/dispatcher.ts +++ b/packages/apollo-server-core/src/utils/dispatcher.ts @@ -27,6 +27,25 @@ export class Dispatcher { ); } + public async invokeHooksUntilNonNull< + TMethodName extends FunctionPropertyNames> + >( + methodName: TMethodName, + ...args: Args + ): Promise>> | null> { + for (const target of this.targets) { + const method = target[methodName]; + if (!(method && typeof method === 'function')) { + continue; + } + const value = await method.apply(target, args); + if (value !== null) { + return value; + } + } + return null; + } + public invokeDidStartHook< TMethodName extends FunctionPropertyNames< Required, diff --git a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts index 950fca7a336..e91221e137d 100644 --- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -36,8 +36,13 @@ import { Config, ApolloServerBase, } from 'apollo-server-core'; +import { Headers } from 'apollo-server-env'; import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions'; import { TracingFormat } from 'apollo-tracing'; +import ApolloServerPluginResponseCache from 'apollo-server-plugin-response-cache'; +import { GraphQLRequestContext } from 'apollo-server-plugin-base'; + +import { mockDate, unmockDate, advanceTimeBy } from '__mocks__/date'; export function createServerInfo( server: AS, @@ -1381,5 +1386,279 @@ export function testApolloServer( expect(resolverDuration).not.toBeGreaterThan(tracing.duration); }); }); + + describe('Response caching', () => { + beforeAll(() => { + mockDate(); + }); + + afterAll(() => { + unmockDate(); + }); + + it('basic caching', async () => { + const typeDefs = gql` + type Query { + cached: String @cacheControl(maxAge: 10) + uncached: String + private: String @cacheControl(maxAge: 9, scope: PRIVATE) + } + `; + + type FieldName = 'cached' | 'uncached' | 'private'; + const fieldNames: FieldName[] = ['cached', 'uncached', 'private']; + const resolverCallCount: Partial> = {}; + const expectedResolverCallCount: Partial< + Record + > = {}; + const expectCacheHit = (fn: FieldName) => + expect(resolverCallCount[fn]).toBe(expectedResolverCallCount[fn]); + const expectCacheMiss = (fn: FieldName) => + expect(resolverCallCount[fn]).toBe(++expectedResolverCallCount[fn]); + + const resolvers = { + Query: {}, + }; + fieldNames.forEach(name => { + resolverCallCount[name] = 0; + expectedResolverCallCount[name] = 0; + resolvers.Query[name] = () => { + resolverCallCount[name]++; + return `value:${name}`; + }; + }); + + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + plugins: [ + ApolloServerPluginResponseCache({ + sessionId: (requestContext: GraphQLRequestContext) => { + return ( + requestContext.request.http.headers.get('session-id') || null + ); + }, + extraCacheKeyData: ( + requestContext: GraphQLRequestContext, + ) => { + return ( + requestContext.request.http.headers.get( + 'extra-cache-key-data', + ) || null + ); + }, + shouldReadFromCache: ( + requestContext: GraphQLRequestContext, + ) => { + return !requestContext.request.http.headers.get( + 'no-read-from-cache', + ); + }, + shouldWriteToCache: ( + requestContext: GraphQLRequestContext, + ) => { + return !requestContext.request.http.headers.get( + 'no-write-to-cache', + ); + }, + }), + ], + }); + + const apolloFetch = createApolloFetch({ uri }); + apolloFetch.use(({ request, options }, next) => { + const headers = (request as any).headers; + if (headers) { + if (!options.headers) { + options.headers = {}; + } + for (const k in headers) { + options.headers[k] = headers[k]; + } + } + next(); + }); + // Make HTTP response headers visible on the result next to 'data'. + apolloFetch.useAfter(({ response }, next) => { + response.parsed.httpHeaders = response.headers; + next(); + }); + // Use 'any' because we're sneaking httpHeaders onto response.parsed. + function httpHeader(result: any, header: string): string | null { + const value = (result.httpHeaders as Headers).get(header); + // hack: hapi sets cache-control: no-cache by default; make it + // look to our tests like the other servers. + if (header === 'cache-control' && value === 'no-cache') { + return null; + } + return value; + } + + const basicQuery = '{ cached }'; + const fetch = async () => { + const result = await apolloFetch({ + query: basicQuery, + }); + expect(result.data.cached).toBe('value:cached'); + return result; + }; + + // Cache miss + { + const result = await fetch(); + expectCacheMiss('cached'); + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=10, public', + ); + expect(httpHeader(result, 'age')).toBe(null); + } + + // Cache hit + { + const result = await fetch(); + expectCacheHit('cached'); + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=10, public', + ); + expect(httpHeader(result, 'age')).toBe('0'); + } + + // Cache hit partway to ttl. + advanceTimeBy(5 * 1000); + { + const result = await fetch(); + expectCacheHit('cached'); + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=10, public', + ); + expect(httpHeader(result, 'age')).toBe('5'); + } + + // Cache miss after ttl. + advanceTimeBy(6 * 1000); + { + const result = await fetch(); + expectCacheMiss('cached'); + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=10, public', + ); + expect(httpHeader(result, 'age')).toBe(null); + } + + // Cache hit. + { + const result = await fetch(); + expectCacheHit('cached'); + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=10, public', + ); + expect(httpHeader(result, 'age')).toBe('0'); + } + + // For now, caching is based on the original document text, not the AST, + // so this should be a cache miss. + { + const result = await apolloFetch({ + query: '{ cached }', + }); + expect(result.data.cached).toBe('value:cached'); + expectCacheMiss('cached'); + } + + // This definitely should be a cache miss because the output is different. + { + const result = await apolloFetch({ + query: '{alias: cached}', + }); + expect(result.data.alias).toBe('value:cached'); + expectCacheMiss('cached'); + } + + // Reading both a cached and uncached data should not get cached (it's a + // full response cache). + { + const result = await apolloFetch({ + query: '{cached uncached}', + }); + expect(result.data.cached).toBe('value:cached'); + expect(result.data.uncached).toBe('value:uncached'); + expectCacheMiss('cached'); + expectCacheMiss('uncached'); + expect(httpHeader(result, 'cache-control')).toBe(null); + expect(httpHeader(result, 'age')).toBe(null); + } + + // Just double-checking that it didn't get cached. + { + const result = await apolloFetch({ + query: '{cached uncached}', + }); + expect(result.data.cached).toBe('value:cached'); + expect(result.data.uncached).toBe('value:uncached'); + expectCacheMiss('cached'); + expectCacheMiss('uncached'); + expect(httpHeader(result, 'cache-control')).toBe(null); + expect(httpHeader(result, 'age')).toBe(null); + } + + // Let's just remind ourselves that the basic query is cacheable. + { + await apolloFetch({ query: basicQuery }); + expectCacheHit('cached'); + } + + // But if we give it some extra cache key data, it'll be cached separately. + { + const result = await apolloFetch({ + query: basicQuery, + headers: { 'extra-cache-key-data': 'foo' }, + } as any); + expect(result.data.cached).toBe('value:cached'); + expectCacheMiss('cached'); + } + + // But if we give it the same extra cache key data twice, it's a hit. + { + const result = await apolloFetch({ + query: basicQuery, + headers: { 'extra-cache-key-data': 'foo' }, + } as any); + expect(result.data.cached).toBe('value:cached'); + expectCacheHit('cached'); + } + + // Without a session ID, private fields won't be cached. + { + const result = await apolloFetch({ + query: '{private}', + } as any); + expect(result.data.private).toBe('value:private'); + expectCacheMiss('private'); + // Note that the HTTP header calculator doesn't know about session + // IDs, so it'll still tell HTTP-level caches to cache this, albeit + // privately. + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=9, private', + ); + expect(httpHeader(result, 'age')).toBe(null); + } + + // See? + { + const result = await apolloFetch({ + query: '{private}', + } as any); + expect(result.data.private).toBe('value:private'); + expectCacheMiss('private'); + expect(httpHeader(result, 'cache-control')).toBe( + 'max-age=9, private', + ); + } + + // XXX test actually setting sessionId + // XXX test shouldReadFromCache + // XXX test shouldWriteToCache + }); + }); }); } diff --git a/packages/apollo-server-plugin-base/src/index.ts b/packages/apollo-server-plugin-base/src/index.ts index 67f73bff995..d3e9076137c 100644 --- a/packages/apollo-server-plugin-base/src/index.ts +++ b/packages/apollo-server-plugin-base/src/index.ts @@ -32,6 +32,17 @@ export interface GraphQLRequestListener> { 'document' | 'operationName' | 'operation' >, ): ValueOrPromise; + // If this hook is defined, it is invoked immediately before GraphQL execution + // would take place. If its return value resolves to a non-null + // GraphQLResponse, that result is used instead of executing the query. + // Hooks from different plugins are invoked in series and the first non-null + // response is used. + responseForOperation?( + requestContext: WithRequired< + GraphQLRequestContext, + 'document' | 'operationName' | 'operation' + >, + ): ValueOrPromise; executionDidStart?( requestContext: WithRequired< GraphQLRequestContext, diff --git a/packages/apollo-server-plugin-response-cache/.npmignore b/packages/apollo-server-plugin-response-cache/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server-plugin-response-cache/CHANGELOG.md b/packages/apollo-server-plugin-response-cache/CHANGELOG.md new file mode 100644 index 00000000000..ef45b84f48c --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +### vNEXT + diff --git a/packages/apollo-server-plugin-response-cache/README.md b/packages/apollo-server-plugin-response-cache/README.md new file mode 100644 index 00000000000..85948d45f02 --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/README.md @@ -0,0 +1,12 @@ +# Response Cache plugin + +This Apollo server plugin implements a full GraphQL query response cache. + +- Add the plugin to your ApolloServer's plugins list +- Set `@cacheControl` hints on your schema or call `info.cacheControl.setCacheHint` in your resolvers +- If the entire GraphQL response is covered by cache hints with non-zero maxAge, + the whole response will be cached. + +This cache is a full query cache: cached responses are only used for identical requests. + + diff --git a/packages/apollo-server-plugin-response-cache/jest.config.js b/packages/apollo-server-plugin-response-cache/jest.config.js new file mode 100644 index 00000000000..a383fbc925f --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/jest.config.js @@ -0,0 +1,3 @@ +const config = require('../../jest.config.base'); + +module.exports = Object.assign(Object.create(null), config); diff --git a/packages/apollo-server-plugin-response-cache/package.json b/packages/apollo-server-plugin-response-cache/package.json new file mode 100644 index 00000000000..73f904ca675 --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/package.json @@ -0,0 +1,30 @@ +{ + "name": "apollo-server-plugin-response-cache", + "version": "0.0.0-alpha.1", + "description": "Apollo Server full query response cache", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-plugin-response-cache" + }, + "keywords": [], + "author": "Apollo ", + "license": "MIT", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "engines": { + "node": ">=6" + }, + "dependencies": { + "apollo-cache-control": "file:../apollo-cache-control", + "apollo-server-caching": "file:../apollo-server-caching", + "apollo-server-plugin-base": "file:../apollo-server-plugin-base", + "apollo-server-env": "file:../apollo-server-env" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" + } +} diff --git a/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts b/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts new file mode 100644 index 00000000000..9d64af788da --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts @@ -0,0 +1,337 @@ +import { + ApolloServerPlugin, + GraphQLRequestListener, + GraphQLRequestContext, +} from 'apollo-server-plugin-base'; +import { KeyValueCache, PrefixingKeyValueCache } from 'apollo-server-caching'; +import { WithRequired, ValueOrPromise } from 'apollo-server-env'; +import { CacheHint, CacheScope } from 'apollo-cache-control'; + +// XXX This should use createSHA from apollo-server-core in order to work on +// non-Node environments. I'm not sure where that should end up --- +// apollo-server-sha as its own tiny module? apollo-server-env seems bad because +// that would add sha.js to unnecessary places, I think? +import { createHash } from 'crypto'; +import { GraphQLResponse } from 'apollo-server-core/dist/requestPipelineAPI'; + +interface Options> { + // Underlying cache used to save results. All writes will be under keys that + // start with 'fqc:' and are followed by a fixed-size cryptographic hash of a + // JSON object with keys representing the query document, operation name, + // variables, and other keys derived from the sessionId and extraCacheKeyData + // hooks. If not provided, use the cache in the GraphQLRequestContext instead + // (ie, the cache passed to the ApolloServer constructor). + cache?: KeyValueCache; + + // Define this hook if you're setting any cache hints with scope PRIVATE. + // This should return a session ID if the user is "logged in", or null if + // there is no "logged in" user. + // + // If a cachable response has any PRIVATE nodes, then: + // - If this hook is not defined, a warning will be logged and it will not be cached. + // - Else if this hook returns null, it will not be cached. + // - Else it will be cached under a cache key tagged with the session ID and + // mode "private". + // + // If a cachable response has no PRIVATE nodes, then: + // - If this hook is not defined or returns null, it will be cached under a cache + // key tagged with the mode "no session". + // - Else it will be cached under a cache key tagged with the mode + // "authenticated public". + // + // When reading from the cache: + // - If this hook is not defined or returns null, look in the cache under a cache + // key tagged with the mode "no session". + // - Else look in the cache under a cache key tagged with the session ID and the + // mode "private". If no response is found in the cache, then look under a cache + // key tagged with the mode "authenticated public". + // + // This allows the cache to provide different "public" results to anonymous + // users and logged in users ("no session" vs "authenticated public"). + // + // A common implementation of this hook would be to look in + // requestContext.request.http.headers for a specific authentication header or + // cookie. + // + // This hook may return a promise because, for example, you might need to + // validate a cookie against an external service. + sessionId?( + requestContext: GraphQLRequestContext, + ): ValueOrPromise; + + // Define this hook if you want the cache key to vary based on some aspect of + // the request other than the query document, operation name, variables, and + // session ID. For example, responses that include translatable text may want + // to return a string derived from + // requestContext.request.http.headers.get('Accept-Language'). The data may + // be anything that can be JSON-stringified. + extraCacheKeyData?( + requestContext: GraphQLRequestContext, + ): ValueOrPromise; + + // If this hook is defined and returns false, the plugin will not read + // responses from the cache. + shouldReadFromCache?( + requestContext: GraphQLRequestContext, + ): ValueOrPromise; + + // If this hook is defined and returns false, the plugin will not write the + // response to the cache. + shouldWriteToCache?( + requestContext: GraphQLRequestContext, + ): ValueOrPromise; +} + +enum SessionMode { + NoSession, + Private, + AuthenticatedPublic, +} + +function sha(s: string) { + return createHash('sha256') + .update(s) + .digest('hex'); +} + +interface BaseCacheKey { + documentText: string; + operationName: string | null; + variables: { [name: string]: any }; + extra: any; +} + +interface ContextualCacheKey { + sessionMode: SessionMode; + sessionId?: string | null; +} + +interface CacheValue { + // Note: we only store data responses in the cache, not errors. + // + // There are two reasons we don't cache errors. The user-level reason is that + // we think that in general errors are less cacheable than real results, since + // they might indicate something transient like a failure to talk to a + // backend. (If you need errors to be cacheable, represent the erroneous + // condition explicitly in data instead of out-of-band as an error.) The + // implementation reason is that this lets us avoid complexities around + // serialization and deserialization of GraphQL errors, and the distinction + // between formatted and unformatted errors, etc. + data: Record; + cachePolicy: Required; + cacheTime: number; // epoch millis, used to calculate Age header +} + +type CacheKey = BaseCacheKey & ContextualCacheKey; + +function cacheKeyString(key: CacheKey) { + return sha(JSON.stringify(key)); +} + +function isGraphQLQuery(requestContext: GraphQLRequestContext) { + return requestContext.operation!.operation === 'query'; +} + +export default function plugin( + options: Options = Object.create(null), +): ApolloServerPlugin { + return { + requestDidStart( + outerRequestContext: GraphQLRequestContext, + ): GraphQLRequestListener { + const cache = new PrefixingKeyValueCache( + options.cache || outerRequestContext.cache!, + 'fqc:', + ); + + let sessionId: string | null = null; + let baseCacheKey: BaseCacheKey | null = null; + let age: number | null = null; + + return { + async responseForOperation( + requestContext: WithRequired< + GraphQLRequestContext, + 'document' | 'operationName' | 'operation' + >, + ): Promise { + requestContext.metrics!.responseCacheHit = false; + + if (!isGraphQLQuery(requestContext)) { + return null; + } + + async function cacheGet( + contextualCacheKeyFields: ContextualCacheKey, + ): Promise { + const key = cacheKeyString({ + ...baseCacheKey!, + ...contextualCacheKeyFields, + }); + const serializedValue = await cache.get(key); + if (serializedValue === undefined) { + return null; + } + + const value: CacheValue = JSON.parse(serializedValue); + // Use cache policy from the cache (eg, to calculate HTTP response + // headers). + // XXX Another alternative would be to directly set the + // cache-control HTTP header here. + requestContext.overallCachePolicy = value.cachePolicy; + requestContext.metrics!.responseCacheHit = true; + age = Math.round((+new Date() - value.cacheTime) / 1000); + return { data: value.data }; + } + + // Call hooks. Save values which will be used in willSendResponse as well. + let extraCacheKeyData: any = null; + if (options.sessionId) { + sessionId = await options.sessionId(requestContext); + } + if (options.extraCacheKeyData) { + extraCacheKeyData = await options.extraCacheKeyData(requestContext); + } + + baseCacheKey = { + documentText: requestContext.documentText!, + operationName: requestContext.operationName, + // Defensive copy just in case it somehow gets mutated. + variables: { ...(requestContext.request.variables || {}) }, + extra: extraCacheKeyData, + }; + + // Note that we set up sessionId and baseCacheKey before doing this + // check, so that we can still write the result to the cache even if + // we are told not to read from the cache. + if ( + options.shouldReadFromCache && + !options.shouldReadFromCache(requestContext) + ) { + return null; + } + + if (sessionId === null) { + return cacheGet({ sessionMode: SessionMode.NoSession }); + } else { + const privateResponse = await cacheGet({ + sessionId, + sessionMode: SessionMode.Private, + }); + if (privateResponse !== null) { + return privateResponse; + } + return cacheGet({ sessionMode: SessionMode.AuthenticatedPublic }); + } + }, + + async willSendResponse( + requestContext: WithRequired, 'response'>, + ) { + if (!isGraphQLQuery(requestContext)) { + return; + } + if (requestContext.metrics!.responseCacheHit) { + // Never write back to the cache what we just read from it. But do set the Age header! + const http = requestContext.response.http; + if (http && age !== null) { + http.headers.set('age', age.toString()); + } + return; + } + if ( + options.shouldWriteToCache && + !options.shouldWriteToCache(requestContext) + ) { + return; + } + + const { response, overallCachePolicy } = requestContext; + if ( + response.errors || + !response.data || + !overallCachePolicy || + overallCachePolicy.maxAge <= 0 + ) { + // This plugin never caches errors or anything without a cache policy. + // + // There are two reasons we don't cache errors. The user-level + // reason is that we think that in general errors are less cacheable + // than real results, since they might indicate something transient + // like a failure to talk to a backend. (If you need errors to be + // cacheable, represent the erroneous condition explicitly in data + // instead of out-of-band as an error.) The implementation reason is + // that this lets us avoid complexities around serialization and + // deserialization of GraphQL errors, and the distinction between + // formatted and unformatted errors, etc. + return; + } + + const data = response.data!; + + // We're pretty sure that any path that calls willSendResponse with a + // non-error response will have already called our execute hook above, + // but let's just double-check that, since accidentally ignoring + // sessionId could be a big security hole. + if (!baseCacheKey) { + throw new Error( + 'willSendResponse called without error, but execute not called?', + ); + } + + function cacheSetInBackground( + contextualCacheKeyFields: ContextualCacheKey, + ) { + const key = cacheKeyString({ + ...baseCacheKey!, + ...contextualCacheKeyFields, + }); + const value: CacheValue = { + data, + cachePolicy: overallCachePolicy!, + cacheTime: +new Date(), + }; + const serializedValue = JSON.stringify(value); + // Note that this function converts key and response to strings before + // doing anything asynchronous, so it can run in parallel with user code + // without worrying about anything being mutated out from under it. + // + // Also note that the test suite assumes that this asynchronous function + // still calls `cache.set` synchronously (ie, that it writes to + // InMemoryLRUCache synchronously). + cache + .set(key, serializedValue, { ttl: overallCachePolicy!.maxAge }) + .catch(console.warn); + } + + const isPrivate = overallCachePolicy.scope === CacheScope.Private; + if (isPrivate) { + if (!options.sessionId) { + console.warn( + 'A GraphQL response used @cacheControl or setCacheHint to set cache hints with scope ' + + "Private, but you didn't define the sessionId hook for " + + 'apollo-server-plugin-response-cache. Not caching.', + ); + return; + } + if (sessionId === null) { + // Private data shouldn't be cached for logged-out users. + return; + } + cacheSetInBackground({ + sessionId, + sessionMode: SessionMode.Private, + }); + } else { + cacheSetInBackground({ + sessionMode: + sessionId === null + ? SessionMode.NoSession + : SessionMode.AuthenticatedPublic, + }); + } + }, + }; + }, + }; +} diff --git a/packages/apollo-server-plugin-response-cache/src/__tests__/ApolloServerPluginResponseCache.test.ts b/packages/apollo-server-plugin-response-cache/src/__tests__/ApolloServerPluginResponseCache.test.ts new file mode 100644 index 00000000000..befaf5c16da --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/src/__tests__/ApolloServerPluginResponseCache.test.ts @@ -0,0 +1,7 @@ +import plugin from '../ApolloServerPluginResponseCache'; + +describe('Response cache plugin', () => { + it('will instantiate when not called with options', () => { + expect(plugin()).toHaveProperty('requestDidStart'); + }); +}); diff --git a/packages/apollo-server-plugin-response-cache/src/__tests__/tsconfig.json b/packages/apollo-server-plugin-response-cache/src/__tests__/tsconfig.json new file mode 100644 index 00000000000..428259da813 --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/src/__tests__/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.test.base", + "include": ["**/*"], + "references": [ + { "path": "../../" } + ] +} diff --git a/packages/apollo-server-plugin-response-cache/src/index.ts b/packages/apollo-server-plugin-response-cache/src/index.ts new file mode 100644 index 00000000000..0a7c63cf6ba --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/src/index.ts @@ -0,0 +1,3 @@ +import plugin from './ApolloServerPluginResponseCache'; +export default plugin; +module.exports = plugin; diff --git a/packages/apollo-server-plugin-response-cache/tsconfig.json b/packages/apollo-server-plugin-response-cache/tsconfig.json new file mode 100644 index 00000000000..31b8b75fe4d --- /dev/null +++ b/packages/apollo-server-plugin-response-cache/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["**/__tests__", "**/__mocks__"], + "references": [ + { "path": "../apollo-cache-control" }, + { "path": "../apollo-server-plugin-base" }, + { "path": "../apollo-server-caching" } + ] +} diff --git a/packages/graphql-extensions/src/index.ts b/packages/graphql-extensions/src/index.ts index 467e4017d4e..68d6a688426 100644 --- a/packages/graphql-extensions/src/index.ts +++ b/packages/graphql-extensions/src/index.ts @@ -49,6 +49,10 @@ export class GraphQLExtension { executionArgs: ExecutionArgs; }): EndHandler | void; + public didResolveOperation?(o: { + requestContext: GraphQLRequestContext; + }): void; + public willSendResponse?(o: { graphqlResponse: GraphQLResponse; context: TContext; @@ -108,6 +112,16 @@ export class GraphQLExtensionStack { ); } + public didResolveOperation(o: { + requestContext: GraphQLRequestContext; + }) { + this.extensions.forEach(extension => { + if (extension.didResolveOperation) { + extension.didResolveOperation(o); + } + }); + } + public willSendResponse(o: { graphqlResponse: GraphQLResponse; context: TContext; diff --git a/tsconfig.build.json b/tsconfig.build.json index cd6cd85dea1..cdc33ba7042 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,6 +25,7 @@ { "path": "./packages/apollo-server-lambda" }, { "path": "./packages/apollo-server-micro" }, { "path": "./packages/apollo-server-plugin-base" }, + { "path": "./packages/apollo-server-plugin-response-cache" }, { "path": "./packages/apollo-server-testing" }, { "path": "./packages/apollo-tracing" }, { "path": "./packages/graphql-extensions" }, diff --git a/tsconfig.test.json b/tsconfig.test.json index aa6f00ceeb8..084abf89259 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -21,5 +21,6 @@ { "path": "./packages/apollo-server-koa/src/__tests__/" }, { "path": "./packages/apollo-server-lambda/src/__tests__/" }, { "path": "./packages/apollo-server-micro/src/__tests__/" }, + { "path": "./packages/apollo-server-plugin-response-cache/src/__tests__/" }, ] }