/
ApolloServerPluginResponseCache.ts
351 lines (315 loc) 路 13.6 KB
/
ApolloServerPluginResponseCache.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
import type {
ApolloServerPlugin,
GraphQLRequestListener,
} from 'apollo-server-plugin-base';
import type {
GraphQLRequestContext,
GraphQLResponse,
CacheHint,
ValueOrPromise,
} from 'apollo-server-types';
import {
KeyValueCache,
PrefixingKeyValueCache,
} from '@apollo/utils.keyvaluecache';
import { CacheScope } from 'apollo-server-types';
// 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';
interface Options<TContext = Record<string, any>> {
// 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 cacheable 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 cacheable 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<TContext>,
): ValueOrPromise<string | null>;
// 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<TContext>,
): ValueOrPromise<any>;
// If this hook is defined and returns false, the plugin will not read
// responses from the cache.
shouldReadFromCache?(
requestContext: GraphQLRequestContext<TContext>,
): ValueOrPromise<boolean>;
// If this hook is defined and returns false, the plugin will not write the
// response to the cache.
shouldWriteToCache?(
requestContext: GraphQLRequestContext<TContext>,
): ValueOrPromise<boolean>;
// This hook allows one to replace the function that is used to create a cache
// key. By default, it is the SHA-256 (from the Node `crypto` package) of the result of
// calling `JSON.stringify(keyData)`. You can override this to customize the serialization
// or the hash, or to make other changes like adding a prefix to keys to allow for
// app-specific prefix-based cache invalidation. You may assume that `keyData` is an object
// and that all relevant data will be found by the kind of iteration performed by
// `JSON.stringify`, but you should not assume anything about the particular fields on
// `keyData`.
generateCacheKey?: GenerateCacheKeyFunction;
}
type GenerateCacheKeyFunction = (
requestContext: GraphQLRequestContext<Record<string, any>>,
keyData: unknown,
) => string;
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<string, any>;
cachePolicy: Required<CacheHint>;
cacheTime: number; // epoch millis, used to calculate Age header
}
function isGraphQLQuery(requestContext: GraphQLRequestContext<any>) {
return requestContext.operation?.operation === 'query';
}
export default function plugin(
options: Options = Object.create(null),
): ApolloServerPlugin {
return {
async requestDidStart(
outerRequestContext: GraphQLRequestContext<any>,
): Promise<GraphQLRequestListener<any>> {
const cache = new PrefixingKeyValueCache(
options.cache || outerRequestContext.cache!,
'fqc:',
);
const generateCacheKey: GenerateCacheKeyFunction =
options.generateCacheKey ?? ((_, key) => sha(JSON.stringify(key)));
let sessionId: string | null = null;
let baseCacheKey: BaseCacheKey | null = null;
let age: number | null = null;
return {
async responseForOperation(
requestContext,
): Promise<GraphQLResponse | null> {
requestContext.metrics.responseCacheHit = false;
if (!isGraphQLQuery(requestContext)) {
return null;
}
async function cacheGet(
contextualCacheKeyFields: ContextualCacheKey,
): Promise<GraphQLResponse | null> {
const cacheKeyData = {
...baseCacheKey!,
...contextualCacheKeyFields,
};
const key = generateCacheKey(requestContext, cacheKeyData);
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.replace(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) {
const shouldReadFromCache = await options.shouldReadFromCache(
requestContext,
);
if (!shouldReadFromCache) 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) {
const logger = requestContext.logger || console;
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) {
const shouldWriteToCache = await options.shouldWriteToCache(
requestContext,
);
if (!shouldWriteToCache) return;
}
const { response } = requestContext;
const { data } = response;
const policyIfCacheable =
requestContext.overallCachePolicy.policyIfCacheable();
if (response.errors || !data || !policyIfCacheable) {
// 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;
}
// 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?',
);
}
const cacheSetInBackground = (
contextualCacheKeyFields: ContextualCacheKey,
): void => {
const cacheKeyData = {
...baseCacheKey!,
...contextualCacheKeyFields,
};
const key = generateCacheKey(requestContext, cacheKeyData);
const value: CacheValue = {
data,
cachePolicy: policyIfCacheable,
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: policyIfCacheable.maxAge })
.catch(logger.warn);
};
const isPrivate = policyIfCacheable.scope === CacheScope.Private;
if (isPrivate) {
if (!options.sessionId) {
logger.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,
});
}
},
};
},
};
}