Skip to content

Commit

Permalink
Full query response cache plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
glasser committed Mar 13, 2019
1 parent 9d39f2c commit e2cc27a
Show file tree
Hide file tree
Showing 23 changed files with 526 additions and 27 deletions.
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/apollo-cache-control/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"node": ">=6.0"
},
"dependencies": {
"apollo-server-env": "file:../apollo-server-core",
"apollo-server-env": "file:../apollo-server-env",
"graphql-extensions": "file:../graphql-extensions"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ export async function collectCacheControlHints(
): Promise<CacheHint[]> {
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,
Expand Down
25 changes: 14 additions & 11 deletions packages/apollo-cache-control/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,17 @@ import {

import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions';

import {
CacheHint,
CacheScope,
} from 'apollo-server-core/dist/requestPipelineAPI';
export { CacheHint, CacheScope };

export interface CacheControlFormat {
version: 1;
hints: ({ path: (string | number)[] } & CacheHint)[];
}

export interface CacheHint {
maxAge?: number;
scope?: CacheScope;
}

export enum CacheScope {
Public = 'PUBLIC',
Private = 'PRIVATE',
}

export interface CacheControlExtensionOptions {
defaultMaxAge?: number;
// FIXME: We should replace these with
Expand Down Expand Up @@ -123,7 +119,14 @@ export class CacheControlExtension<TContext = any>
}

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',
Expand Down
1 change: 1 addition & 0 deletions packages/apollo-cache-control/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"],
"references": [
{ "path": "../apollo-server-core/tsconfig.requestPipelineAPI.json" },
{ "path": "../graphql-extensions" }
]
}
1 change: 1 addition & 0 deletions packages/apollo-server-caching/src/KeyValueCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface KeyValueCache<V = string> {
get(key: string): Promise<V | undefined>;
// ttl is measured in seconds.
set(key: string, value: V, options?: { ttl?: number }): Promise<void>;
delete(key: string): Promise<boolean | void>;
}
Expand Down
49 changes: 35 additions & 14 deletions packages/apollo-server-core/src/requestPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,26 +290,47 @@ export async function processGraphQLRequest<TContext>(
);
}

const executionDidEnd = await dispatcher.invokeDidStartHook(
'executionDidStart',
let response: GraphQLResponse;

// Ask plugins (eg, response cache) for responses.
const pluginGraphQLResponses = (await dispatcher.invokeHookAsync(
'execute',
requestContext as WithRequired<
typeof requestContext,
'document' | 'operation' | 'operationName'
>,
);
)).filter(x => x);
if (pluginGraphQLResponses.length > 1) {
// XXX how hard would it be to make this error message more actionable?
// We'd need to know plugin names...
throw new Error('Multiple plugins provided a direct execution result');
}
if (pluginGraphQLResponses.length > 0) {
response = pluginGraphQLResponses[0]!;
} else {
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) {
requestContext.overallCachePolicy = cacheControlExtension.computeOverallCachePolicy();
}

const formattedExtensions = extensionStack.format();
Expand Down
12 changes: 12 additions & 0 deletions packages/apollo-server-core/src/requestPipelineAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import {
} from 'graphql';
import { KeyValueCache } from 'apollo-server-caching';

export interface CacheHint {
maxAge?: number;
scope?: CacheScope;
}

export enum CacheScope {
Public = 'PUBLIC',
Private = 'PRIVATE',
}

export interface GraphQLServiceContext {
schema: GraphQLSchema;
schemaHash: string;
Expand Down Expand Up @@ -57,6 +67,8 @@ export interface GraphQLRequestContext<TContext = Record<string, any>> {
readonly operationName?: string | null;
readonly operation?: OperationDefinitionNode;

readonly overallCachePolicy?: Required<CacheHint> | undefined;

debug?: boolean;
}

Expand Down
76 changes: 76 additions & 0 deletions packages/apollo-server-integration-testsuite/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import {
} from 'apollo-server-core';
import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions';
import { TracingFormat } from 'apollo-tracing';
import ApolloServerPluginResponseCache from 'apollo-server-plugin-response-cache';

import { mockDate, unmockDate, advanceTimeBy } from '__mocks__/date';

export function createServerInfo<AS extends ApolloServerBase>(
server: AS,
Expand Down Expand Up @@ -1381,5 +1384,78 @@ export function testApolloServer<AS extends ApolloServerBase>(
expect(resolverDuration).not.toBeGreaterThan(tracing.duration);
});
});

describe('Response caching', () => {
const typeDefs = gql`
type Query {
cachedField: String @cacheControl(maxAge: 10)
}
`;

let resolverCallCount = 0;
const resolvers = {
Query: {
cachedField: () => {
resolverCallCount++;
return 'value';
},
},
};

beforeAll(() => {
mockDate();
});

afterAll(() => {
unmockDate();
});

it('basic caching', async () => {
const { url: uri } = await createApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginResponseCache()],
});

const apolloFetch = createApolloFetch({ uri });

const fetch = async () => {
const result = await apolloFetch({
query: `{ cachedField }`,
});
expect(result.data.cachedField).toBe('value');
};

await fetch();
expect(resolverCallCount).toBe(1);

await fetch();
expect(resolverCallCount).toBe(1);

advanceTimeBy(5 * 1000);

await fetch();
expect(resolverCallCount).toBe(1);

advanceTimeBy(6 * 1000);
await fetch();
expect(resolverCallCount).toBe(2);

await fetch();
expect(resolverCallCount).toBe(2);

const textChangedASTUnchangedResult = await apolloFetch({
query: `{ cachedField }`,
});
expect(textChangedASTUnchangedResult.data.cachedField).toBe('value');
expect(resolverCallCount).toBe(2);

const slightlyDifferentQueryResult = await apolloFetch({
query: `{alias: cachedField}`,
});
expect(slightlyDifferentQueryResult.data.alias).toBe('value');
expect(resolverCallCount).toBe(3);
});
});
});
}
13 changes: 12 additions & 1 deletion packages/apollo-server-plugin-base/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,23 @@ export interface GraphQLRequestListener<TContext = Record<string, any>> {
'document' | 'operationName' | 'operation'
>,
): ValueOrPromise<void>;
// 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. It is
// an error for more than one plugin to return a value that resolves to a
// non-null GraphQLResponse.
execute?(
requestContext: WithRequired<
GraphQLRequestContext<TContext>,
'document' | 'operationName' | 'operation'
>,
): ValueOrPromise<GraphQLResponse | null>;
executionDidStart?(
requestContext: WithRequired<
GraphQLRequestContext<TContext>,
'document' | 'operationName' | 'operation'
>,
): (err?: Error) => void | void;
): ((err?: Error) => void) | void;
willSendResponse?(
requestContext: WithRequired<GraphQLRequestContext<TContext>, 'response'>,
): ValueOrPromise<void>;
Expand Down
6 changes: 6 additions & 0 deletions packages/apollo-server-plugin-response-cache/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*
!src/**/*
!dist/**/*
dist/**/*.test.*
!package.json
!README.md
4 changes: 4 additions & 0 deletions packages/apollo-server-plugin-response-cache/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Change Log

### vNEXT

12 changes: 12 additions & 0 deletions packages/apollo-server-plugin-response-cache/README.md
Original file line number Diff line number Diff line change
@@ -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.


3 changes: 3 additions & 0 deletions packages/apollo-server-plugin-response-cache/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const config = require('../../jest.config.base');

module.exports = Object.assign(Object.create(null), config);
29 changes: 29 additions & 0 deletions packages/apollo-server-plugin-response-cache/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"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 <[email protected]>",
"license": "MIT",
"bugs": {
"url": "https://github.com/apollographql/apollo-server/issues"
},
"homepage": "https://github.com/apollographql/apollo-server#readme",
"engines": {
"node": ">=6"
},
"dependencies": {
"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"
}
}
Loading

0 comments on commit e2cc27a

Please sign in to comment.