diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1037ad64491..1af1f4ad94e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
### v2.5.0
+- New plugin package `apollo-server-plugin-response-cache` implementing a full query response cache based on `apollo-cache-control` hints. The implementation added a few hooks and context fields; see the PR for details. There is a slight change to `cacheControl` object: previously, `cacheControl.stripFormattedExtensions` defaulted to false if you did not provide a `cacheControl` option object, but defaulted to true if you provided (eg) `cacheControl: {defaultMaxAge: 10}`. Now `stripFormattedExtensions` defaults to false unless explicitly provided as `true`, or if you use the legacy boolean `cacheControl: true`. [PR #2437](https://github.com/apollographql/apollo-server/pull/2437)
- Allow `GraphQLRequestListener` callbacks in plugins to depend on `this`. [PR #2470](https://github.com/apollographql/apollo-server/pull/2470)
- Move shared TypeScript utility types `WithRequired` and `ValueOrPromise` into `apollo-server-env`. [PR #2415](https://github.com/apollographql/apollo-server/pull/2415) [PR #2417](https://github.com/apollographql/apollo-server/pull/2417)
diff --git a/docs/_config.yml b/docs/_config.yml
index 0df030fa1b9..2aabd6215c5 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -18,6 +18,7 @@ sidebar_categories:
- features/mocking
- features/errors
- features/data-sources
+ - features/caching
- features/subscriptions
- features/metrics
- features/graphql-playground
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 9a8e1e3368f..816323ea45a 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -1583,7 +1583,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"aproba": {
"version": "1.2.0",
@@ -1998,7 +1999,8 @@
"safe-buffer": {
"version": "5.1.1",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -2054,6 +2056,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -2097,12 +2100,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"yallist": {
"version": "3.0.2",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
}
}
},
@@ -3003,7 +3008,8 @@
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz",
"integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=",
- "dev": true
+ "dev": true,
+ "optional": true
},
"hosted-git-info": {
"version": "2.7.1",
@@ -4295,7 +4301,8 @@
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
"integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=",
- "dev": true
+ "dev": true,
+ "optional": true
},
"braces": {
"version": "2.3.2",
@@ -6320,7 +6327,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
- "dev": true
+ "dev": true,
+ "optional": true
},
"ws": {
"version": "3.3.3",
diff --git a/docs/source/api/apollo-server.md b/docs/source/api/apollo-server.md
index e51fa782655..420ab097eea 100644
--- a/docs/source/api/apollo-server.md
+++ b/docs/source/api/apollo-server.md
@@ -98,7 +98,7 @@ new ApolloServer({
* `tracing`, `cacheControl`: <`Boolean`>
- Add tracing or cacheControl meta data to the GraphQL response
+ If set to true, adds tracing or cacheControl meta data to the GraphQL response. This is primarily intended for use with the deprecated Engine proxy. `cacheControl` can also be set to an object to specify arguments to the `apollo-cache-control` package, including `defaultMaxAge`, `calculateHttpHeaders`, and `stripFormattedExtensions`.
* `formatError`, `formatResponse`: <`Function`>
diff --git a/docs/source/features/caching.md b/docs/source/features/caching.md
new file mode 100644
index 00000000000..e2e35de2167
--- /dev/null
+++ b/docs/source/features/caching.md
@@ -0,0 +1,150 @@
+---
+title: Caching
+description: Automatically set HTTP cache headers and save full responses in a cache.
+---
+
+Production apps often rely on caching for scalability.
+
+A single GraphQL request consists of running many different resolvers, each of which can have different caching semantics. Some fields may be uncacheable. Some fields may be cacheable for a few seconds, and others for a few hours. Some fields may have values that are the same for all users of your app, and other fields may vary based on the current session.
+
+Apollo Server provides a mechanism for server authors to declare fine-grained cache control parameters on individual GraphQL types and fields, both statically inside your schema using the `@cacheControl` directive and dynamically within your resolvers using the `info.cacheControl.setCacheHint` API.
+
+For each request, Apollo Server combines all the cache hints from all the queried fields and uses it to power several caching features. These features include **HTTP caching headers** for CDNs and browsers, and a GraphQL **full response cache**.
+
+
+## Defining cache hints
+
+You can define cache hints *statically* in your schema and *dynamically* in your resolvers.
+
+### Adding cache hints statically in your schema
+
+The easiest way to add cache hints is directly in your schema using the `@cacheControl` directive. Apollo Server automatically adds the definition of the `@cacheControl` directive to your schema when you create a new `ApolloServer` object with `typeDefs` and `resolvers`. Hints look like this:
+
+```graphql
+type Post @cacheControl(maxAge: 240) {
+ id: Int!
+ title: String
+ author: Author
+ votes: Int @cacheControl(maxAge: 30)
+ comments: [Comment]
+ readByCurrentUser: Boolean! @cacheControl(scope: PRIVATE)
+}
+
+type Comment @cacheControl(maxAge: 1000) {
+ post: Post!
+}
+
+type Query {
+ latestPost: Post @cacheControl(maxAge: 10)
+}
+```
+
+You can apply `@cacheControl` to an individual field or to a type.
+
+Hints on a field describe the cache policy for that field itself; for example, `Post.votes` can be cached for 30 seconds.
+
+Hints on a type apply to all fields that *return* objects of that type (possibly wrapped in lists and non-null specifiers). For example, the hint `@cacheControl(maxAge: 240)` on `Post` applies to the field `Comment.post`, and the hint `@cacheControl(maxAge:1000)` on `Comment` applies to the field `Post.comments`.
+
+Hints on fields override hints specified on the target type. For example, the hint `@cacheControl(maxAge: 10)` on `Query.latestPost` takes precedence over the hint `@cacheControl(maxAge: 240)` on `Post`.
+
+See [below](#default-maxage) for the semantics of fields which don't have `maxAge` set on them (statically or dynamically).
+
+`@cacheControl` can specify `maxAge` (in seconds, like in an HTTP `Cache-Control` header) and `scope`, which can be `PUBLIC` (the default) or `PRIVATE`.
+
+
+### Adding cache hints dynamically in your resolvers
+
+If you won't know if a field is cacheable until you've actually resolved it, you can use the dynamic API to set hints in your resolvers:
+
+```javascript
+const resolvers = {
+ Query: {
+ post: (_, { id }, _, info) => {
+ info.cacheControl.setCacheHint({ maxAge: 60, scope: 'PRIVATE' });
+ return find(posts, { id });
+ }
+ }
+}
+```
+
+If you're using TypeScript, you need the following to teach TypeScript that the GraphQL `info` object has a `cacheControl` field:
+```javascript
+import 'apollo-cache-control';
+```
+
+
+
Setting a default `maxAge`
+
+By default, root fields (ie, fields on `Query` and `Mutation`) and fields returning object and interface types are considered to have a `maxAge` of 0 (ie, uncacheable) if they don't have a static or dynamic cache hint. (Non-root scalar fields inherit their cacheability from their parent, so that in the common case of an object type with a bunch of strings and numbers which all have the same cacheability, you just need to declare the hint on the object type.)
+
+The power of cache hints comes from being able to set them precisely to different values on different types and fields based on your understanding of your implementation's semantics. But when getting started with the cache control API, you might just want to apply the same `maxAge` to most of your resolvers.
+
+You can achieve this by specifying a default max age when you create your `ApolloServer`. This max age will be used instead of 0 for root, object, and interface fields which don't explicitly set `maxAge` via schema hints (including schema hints on the type that they return) or the dynamic API. You can override this for a particular resolver or type by setting `@cacheControl(maxAge: 0)`. For example:
+
+```javascript
+const server = new ApolloServer({
+ // ...
+ cacheControl: {
+ defaultMaxAge: 5,
+ },
+}));
+```
+
+
+### The overall cache policy
+
+Apollo Server's cache API lets you declare fine-grained cache hints on specific resolvers. Apollo Server then combines these hints into an overall cache policy for the response. The `maxAge` of this policy is the minimum `maxAge` across all fields in your request. As [described above](#default-maxage), the default `maxAge` of all root fields and non-scalar fields is 0, so the overall cache policy for a response will have `maxAge` 0 (ie, uncacheable) unless all root and non-scalar fields in the response have cache hints (or if `defaultMaxAge` is specified).
+
+If the overall cache policy has a non-zero `maxAge`, its scope is `PRIVATE` if any hints have scope `PRIVATE`, and `PUBLIC` otherwise.
+
+
+
+
+For any response whose overall cache policy has a non-zero `maxAge`, Apollo Server will automatically set the `Cache-Control` HTTP response header to an appropriate value describing the `maxAge` and scope, such as `Cache-Control: max-age=60, private`. If you run your Apollo Server instance behind a [CDN](https://en.wikipedia.org/wiki/Content_delivery_network) or other caching proxy, it can use this header's value to know how to cache your GraphQL responses.
+
+As many CDNs and caching proxies only cache GET requests (not POST requests) and may have a limit on the size of a GET URL, you may find it helpful to use [automatic persisted queries](https://github.com/apollographql/apollo-link-persisted-queries), especially with the `useGETForHashedQueries` option to `apollo-link-persisted-queries`.
+
+If you don't want to set HTTP cache headers, pass `cacheControl: {calculateHttpHeaders: false}` to `new ApolloServer()`.
+
+
+## Saving full responses to a cache
+
+Apollo Server lets you save cacheable responses to a Redis, Memcached, or in-process cache. Cached responses respect the `maxAge` cache hint.
+
+To use the response cache, you need to install its plugin when you create your `ApolloServer`:
+
+```javascript
+import responseCachePlugin from 'apollo-server-plugin-response-cache';
+const server = new ApolloServer({
+ // ...
+ plugins: [responseCachePlugin()],
+});
+```
+
+By default, the response cache plugin will use the same cache used by other Apollo Server features, which defaults to an in-memory LRU cache. When running multiple server instances, you’ll want to use a shared cache backend such as Memcached or Redis instead. See [the data sources documentation](./data-sources.html#Using-Memcached-Redis-as-a-cache-storage-backend) for details on how to customize Apollo Server's cache. If you want to use a different cache backed for the response cache than for other Apollo Server caching features, just pass a `KeyValueCache` as the `cache` option to the `responseCachePlugin` function.
+
+If you have data whose response should be cached separately for different users, set `@cacheControl(scope: PRIVATE)` hints on the data, and teach the cache control plugin how to tell your users apart by defining a `sessionId` hook:
+
+```javascript
+import responseCachePlugin from 'apollo-server-plugin-response-cache';
+const server = new ApolloServer({
+ // ...
+ plugins: [responseCachePlugin({
+ sessionId: (requestContext) => (requestContext.request.http.headers.get('sessionid') || null),
+ })],
+});
+```
+
+Responses whose overall cache policy scope is `PRIVATE` are shared only among sessions with the same session ID. Private responses are not cached if the `sessionId` hook is not defined or returns null.
+
+Responses whose overall cache policy scope is `PUBLIC` are shared separately among all sessions with `sessionId` null and among all sessions with non-null `sessionId`. Caching these separately allows you to have different caches for all logged-in users vs all logged-out users, if there is easily cacheable data that should only be visible to logged-in users.
+
+Responses containing GraphQL errors or no data are never cached.
+
+The plugin allows you to define a few more hooks to affect cache behavior for a specific request. All hooks take in a `GraphQLRequestContext`.
+
+- `extraCacheKeyData`: this hook can return any JSON-stringifiable object which is added to the cache key. For example, if your API includes translatable text, this hook can return a string derived from `requestContext.request.http.headers.get('Accept-Language')`.
+- `shouldReadFromCache`: if this hook returns false, the plugin will not read responses from the cache.
+- `shouldWriteToCache`: if this hook returns false, the plugin will not write responses to the cache.
+
+In addition to the [`Cache-Control` HTTP header](#http-cache-headers), the response cache plugin will also set the `Age` HTTP header to the number of seconds the value has bee sitting in the cache.
diff --git a/docs/source/whats-new.md b/docs/source/whats-new.md
index 43fd1b0fd83..f55b5a47133 100644
--- a/docs/source/whats-new.md
+++ b/docs/source/whats-new.md
@@ -74,7 +74,7 @@ For more information on automatic persisted queries, check the [APQ section of t
### CDN integration
-Apollo Server works well with a Content-Distribution Network to cache full GraphQL query results. Apollo Server provides `cache-control` headers that a CDN uses to determine how long a request should be cached. For subsequent requests, the result will be served directly from the CDN's cache. A CDN paired with Apollo Server's persisted queries is especially powerful, since GraphQL operations can be shortened and sent with a HTTP GET request. To enable caching and a CDN in Apollo Server, follow the [Performance Guide](https://www.apollographql.com/docs/guides/performance.html#cdn).
+Apollo Server works well with a Content-Distribution Network to cache full GraphQL query results. Apollo Server provides `cache-control` headers that a CDN uses to determine how long a request should be cached. For subsequent requests, the result will be served directly from the CDN's cache. A CDN paired with Apollo Server's persisted queries is especially powerful, since GraphQL operations can be shortened and sent with a HTTP GET request. Read more about [caching in Apollo Server](./features/caching.html).
### GraphQL errors
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..ff719f75d96 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 to 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..8525a2234ec 100644
--- a/packages/apollo-engine-reporting/src/extension.ts
+++ b/packages/apollo-engine-reporting/src/extension.ts
@@ -1,11 +1,10 @@
-import { Request } from 'apollo-server-env';
+import { Request, WithRequired } from 'apollo-server-env';
import {
GraphQLResolveInfo,
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,11 +91,9 @@ export class EngineReportingExtension
queryString?: string;
parsedQuery?: DocumentNode;
variables?: Record;
- persistedQueryHit?: boolean;
- persistedQueryRegister?: boolean;
context: TContext;
extensions?: Record;
- requestContext: GraphQLRequestContext;
+ requestContext: WithRequired, 'metrics'>;
}): EndHandler {
this.trace.startTime = dateToTimestamp(new Date());
this.startHrTime = process.hrtime();
@@ -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..fd836ca476b 100644
--- a/packages/apollo-server-caching/src/KeyValueCache.ts
+++ b/packages/apollo-server-caching/src/KeyValueCache.ts
@@ -1,5 +1,8 @@
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..e371e4d9772 100644
--- a/packages/apollo-server-core/src/requestPipeline.ts
+++ b/packages/apollo-server-core/src/requestPipeline.ts
@@ -109,6 +109,11 @@ export async function processGraphQLRequest(
initializeDataSources();
+ const metrics = requestContext.metrics || Object.create(null);
+ if (!requestContext.metrics) {
+ requestContext.metrics = metrics;
+ }
+
const request = requestContext.request;
let { query, extensions } = request;
@@ -116,8 +121,8 @@ export async function processGraphQLRequest(
let queryHash: string;
let persistedQueryCache: KeyValueCache | undefined;
- let persistedQueryHit = false;
- let persistedQueryRegister = false;
+ metrics.persistedQueryHit = false;
+ metrics.persistedQueryRegister = false;
if (extensions && extensions.persistedQuery) {
// It looks like we've received a persisted query. Check if we
@@ -150,7 +155,7 @@ export async function processGraphQLRequest(
if (query === undefined) {
query = await persistedQueryCache.get(queryHash);
if (query) {
- persistedQueryHit = true;
+ metrics.persistedQueryHit = true;
} else {
throw new PersistedQueryNotFoundError();
}
@@ -167,7 +172,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;
+ metrics.persistedQueryRegister = true;
}
} else if (query) {
// FIXME: We'll compute the APQ query hash to use as our cache key for
@@ -178,6 +183,7 @@ export async function processGraphQLRequest(
}
requestContext.queryHash = queryHash;
+ requestContext.source = query;
const requestDidEnd = extensionStack.requestDidStart({
request: request.http!,
@@ -185,9 +191,9 @@ export async function processGraphQLRequest(
operationName: request.operationName,
variables: request.variables,
extensions: request.extensions,
- persistedQueryHit,
- persistedQueryRegister,
context: requestContext.context,
+ persistedQueryHit: metrics.persistedQueryHit,
+ persistedQueryRegister: metrics.persistedQueryRegister,
requestContext,
});
@@ -212,7 +218,10 @@ export async function processGraphQLRequest(
if (!requestContext.document) {
const parsingDidEnd = await dispatcher.invokeDidStartHook(
'parsingDidStart',
- requestContext,
+ requestContext as WithRequired<
+ typeof requestContext,
+ 'metrics' | 'source'
+ >,
);
try {
@@ -284,32 +293,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 (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' | 'metrics'
+ >,
+ );
- 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();
@@ -318,9 +348,15 @@ export async function processGraphQLRequest(
}
if (config.formatResponse) {
- response = config.formatResponse(response, {
- context: requestContext.context,
- });
+ const formattedResponse: GraphQLResponse | null = config.formatResponse(
+ response,
+ {
+ context: requestContext.context,
+ },
+ );
+ if (formattedResponse != null) {
+ response = formattedResponse;
+ }
}
return sendResponse(response);
diff --git a/packages/apollo-server-core/src/requestPipelineAPI.ts b/packages/apollo-server-core/src/requestPipelineAPI.ts
index a4d00fc8999..65b4a6b43ee 100644
--- a/packages/apollo-server-core/src/requestPipelineAPI.ts
+++ b/packages/apollo-server-core/src/requestPipelineAPI.ts
@@ -39,6 +39,12 @@ export interface GraphQLResponse {
http?: Pick;
}
+export interface GraphQLRequestMetrics {
+ persistedQueryHit?: boolean;
+ persistedQueryRegister?: boolean;
+ responseCacheHit?: boolean;
+}
+
export interface GraphQLRequestContext> {
readonly request: GraphQLRequest;
readonly response?: GraphQLResponse;
@@ -50,6 +56,7 @@ export interface GraphQLRequestContext> {
readonly queryHash?: string;
readonly document?: DocumentNode;
+ readonly source?: string;
// `operationName` is set based on the operation AST, so it is defined
// even if no `request.operationName` was passed in.
@@ -57,6 +64,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..1f4882dcc75 100644
--- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts
+++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts
@@ -28,7 +28,7 @@ import {
VERSION,
} from 'apollo-link-persisted-queries';
-import { createApolloFetch } from 'apollo-fetch';
+import { createApolloFetch, GraphQLRequest } from 'apollo-fetch';
import {
AuthenticationError,
UserInputError,
@@ -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,420 @@ 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;
+ }
+ // Just for the typing.
+ function doFetch(
+ options: GraphQLRequest & { headers?: Record },
+ ) {
+ return apolloFetch(options as any);
+ }
+
+ const basicQuery = '{ cached }';
+ const fetch = async () => {
+ const result = await doFetch({
+ 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 doFetch({
+ 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 doFetch({
+ 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 doFetch({
+ 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 doFetch({
+ 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 doFetch({ query: basicQuery });
+ expectCacheHit('cached');
+ }
+
+ // But if we give it some extra cache key data, it'll be cached separately.
+ {
+ const result = await doFetch({
+ query: basicQuery,
+ headers: { 'extra-cache-key-data': 'foo' },
+ });
+ 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 doFetch({
+ query: basicQuery,
+ headers: { 'extra-cache-key-data': 'foo' },
+ });
+ expect(result.data.cached).toBe('value:cached');
+ expectCacheHit('cached');
+ }
+
+ // Without a session ID, private fields won't be cached.
+ {
+ const result = await doFetch({
+ query: '{private}',
+ });
+ 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 doFetch({
+ query: '{private}',
+ });
+ expect(result.data.private).toBe('value:private');
+ expectCacheMiss('private');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=9, private',
+ );
+ }
+
+ // OK, how about with a session ID. First try should be a miss.
+ {
+ const result = await doFetch({
+ query: '{private}',
+ headers: { 'session-id': 'foo' },
+ });
+ expect(result.data.private).toBe('value:private');
+ expectCacheMiss('private');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=9, private',
+ );
+ }
+
+ // But next try should be a hit.
+ {
+ const result = await doFetch({
+ query: '{private}',
+ headers: { 'session-id': 'foo' },
+ });
+ expect(result.data.private).toBe('value:private');
+ expectCacheHit('private');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=9, private',
+ );
+ }
+
+ // But a different session ID should be a miss again.
+ {
+ const result = await doFetch({
+ query: '{private}',
+ headers: { 'session-id': 'bar' },
+ });
+ expect(result.data.private).toBe('value:private');
+ expectCacheMiss('private');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=9, private',
+ );
+ }
+
+ // As should be no session.
+ {
+ const result = await doFetch({
+ query: '{private}',
+ });
+ expect(result.data.private).toBe('value:private');
+ expectCacheMiss('private');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=9, private',
+ );
+ }
+
+ // Let's remind ourselves once again that the basic (public) query is *still* cached.
+ {
+ const result = await doFetch({ query: basicQuery });
+ expectCacheHit('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ }
+
+ // If you're logged in, though, you get your own cache shared with all
+ // other authenticated users (the "authenticated public" cache), so this
+ // is a miss. It's still a public cache, though, for the HTTP header.
+ // XXX Does that makes sense? Maybe this should be private, or maybe we
+ // should drop the entire "authenticated public" concept.
+ {
+ const result = await doFetch({
+ query: basicQuery,
+ headers: { 'session-id': 'bar' },
+ });
+ expect(result.data.cached).toBe('value:cached');
+ expectCacheMiss('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ }
+
+ // See, this other session sees it!
+ {
+ const result = await doFetch({
+ query: basicQuery,
+ headers: { 'session-id': 'baz' },
+ });
+ expect(result.data.cached).toBe('value:cached');
+ expectCacheHit('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ expect(httpHeader(result, 'age')).toBe('0');
+ }
+
+ // Let's continue to remind ourselves that the basic (public) query is *still* cached.
+ {
+ const result = await doFetch({ query: basicQuery });
+ expectCacheHit('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ }
+
+ // But what if we specifically ask to not read from the cache?
+ {
+ const result = await doFetch({
+ query: basicQuery,
+ headers: { 'no-read-from-cache': 'y' },
+ });
+ expect(result.data.cached).toBe('value:cached');
+ expectCacheMiss('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ }
+
+ // Let's expire the cache, and run again, not writing to the cache.
+ advanceTimeBy(15 * 1000);
+ {
+ const result = await doFetch({
+ query: basicQuery,
+ headers: { 'no-write-to-cache': 'y' },
+ });
+ expect(result.data.cached).toBe('value:cached');
+ expectCacheMiss('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ }
+
+ // And now verify that in fact we did not write!
+ {
+ const result = await doFetch({
+ query: basicQuery,
+ });
+ expect(result.data.cached).toBe('value:cached');
+ expectCacheMiss('cached');
+ expect(httpHeader(result, 'cache-control')).toBe(
+ 'max-age=10, public',
+ );
+ }
+ });
+ });
});
}
diff --git a/packages/apollo-server-plugin-base/src/index.ts b/packages/apollo-server-plugin-base/src/index.ts
index 67f73bff995..79e650caeb5 100644
--- a/packages/apollo-server-plugin-base/src/index.ts
+++ b/packages/apollo-server-plugin-base/src/index.ts
@@ -21,24 +21,44 @@ export interface ApolloServerPlugin {
export interface GraphQLRequestListener> {
parsingDidStart?(
- requestContext: GraphQLRequestContext,
+ requestContext: WithRequired<
+ GraphQLRequestContext,
+ 'metrics' | 'source'
+ >,
): (err?: Error) => void | void;
validationDidStart?(
- requestContext: WithRequired, 'document'>,
+ requestContext: WithRequired<
+ GraphQLRequestContext,
+ 'metrics' | 'source' | 'document'
+ >,
): (err?: ReadonlyArray) => void | void;
didResolveOperation?(
requestContext: WithRequired<
GraphQLRequestContext,
- 'document' | 'operationName' | 'operation'
+ 'metrics' | 'source' | '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,
+ 'metrics' | 'source' | 'document' | 'operationName' | 'operation'
+ >,
+ ): ValueOrPromise;
executionDidStart?(
requestContext: WithRequired<
GraphQLRequestContext,
- 'document' | 'operationName' | 'operation'
+ 'metrics' | 'source' | 'document' | 'operationName' | 'operation'
>,
): (err?: Error) => void | void;
willSendResponse?(
- requestContext: WithRequired, 'response'>,
+ requestContext: WithRequired<
+ GraphQLRequestContext,
+ 'metrics' | 'response'
+ >,
): ValueOrPromise;
}
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..615e5be6e10
--- /dev/null
+++ b/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts
@@ -0,0 +1,330 @@
+import {
+ ApolloServerPlugin,
+ GraphQLRequestListener,
+ GraphQLRequestContext,
+} from 'apollo-server-plugin-base';
+import { KeyValueCache, PrefixingKeyValueCache } from 'apollo-server-caching';
+import { 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 {
+ source: 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,
+ ): 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).
+ 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 = {
+ source: requestContext.source!,
+ 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) {
+ 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__/" },
]
}