-
Notifications
You must be signed in to change notification settings - Fork 8
/
responseCachePlugin.ts
408 lines (367 loc) · 15.9 KB
/
responseCachePlugin.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
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
import type { Redis } from 'ioredis'
import type { CacheHint } from '@apollo/cache-control-types'
import {
type ApolloServerPlugin,
type BaseContext,
type GraphQLRequestContext,
type GraphQLRequestListener,
type GraphQLResponse,
HeaderMap,
} from '@apollo/server'
import { createHash } from '@apollo/utils.createhash'
import { CACHE_KEY_PREFIX_FQC } from '../enums'
import { recordNodeFQCMapping } from '../utils'
export interface ApolloServerPluginResponseCacheOptions<
TContext extends BaseContext
> {
// 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).
// As `sadd` and `srem` are used, direct Redis connection is required.
redis: Redis
// 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.
//
// Note: this hook has been updated in Apollo Server v4 to only return a
// Promise. This function should always be `await`ed, that way non-TS users
// won't experience a breakage (we can await Promises as well as values).
sessionId?(
requestContext: GraphQLRequestContext<TContext>
): Promise<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.
//
// Note: this hook has been updated in Apollo Server v4 to only return a
// Promise. This function should always be `await`ed, that way non-TS users
// won't experience a breakage (we can await Promises as well as values).
extraCacheKeyData?(
requestContext: GraphQLRequestContext<TContext>
): Promise<any>
// If this hook is defined and returns false, the plugin will not read
// responses from the cache.
//
// Note: this hook has been updated in Apollo Server v4 to only return a
// Promise. This function should always be `await`ed, that way non-TS users
// won't experience a breakage (we can await Promises as well as values).
shouldReadFromCache?(
requestContext: GraphQLRequestContext<TContext>
): Promise<boolean>
// If this hook is defined and returns false, the plugin will not write the
// response to the cache.
//
// Note: this hook has been updated in Apollo Server v4 to only return a
// Promise. This function should always be `await`ed, that way non-TS users
// won't experience a breakage (we can await Promises as well as values).
shouldWriteToCache?(
requestContext: GraphQLRequestContext<TContext>
): Promise<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?(
requestContext: GraphQLRequestContext<Record<string, any>>,
keyData: unknown
): string
// Cache TTL for Node and FQC hashes mapping
nodeFQCTTL: number
}
enum SessionMode {
NoSession,
Private,
AuthenticatedPublic,
}
function sha(s: string) {
return createHash('sha256').update(s).digest('hex')
}
interface BaseCacheKeyData {
source: string
operationName: string | null
variables: { [name: string]: any }
extra: any
}
interface ContextualCacheKeyData {
sessionMode: SessionMode
sessionId?: string | null
}
// We split the CacheKey type into two pieces just for convenience in the code
// below. Note that we don't actually export this type publicly (the
// generateCacheKey hook gets an `unknown` argument).
type CacheKeyData = BaseCacheKeyData & ContextualCacheKeyData
type GenerateCacheKeyFunction = (
requestContext: GraphQLRequestContext<Record<string, any>>,
keyData: CacheKeyData
) => string
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<TContext extends BaseContext>(
options: ApolloServerPluginResponseCacheOptions<TContext> = Object.create(
null
)
): ApolloServerPlugin<TContext> {
return {
async requestDidStart(): Promise<GraphQLRequestListener<any>> {
const redis = options.redis
const generateCacheKey: GenerateCacheKeyFunction =
options.generateCacheKey ?? ((_, key) => sha(JSON.stringify(key)))
let sessionId: string | null = null
let baseCacheKey: BaseCacheKeyData | null = null
let age: number | null = null
return {
async responseForOperation(
requestContext
): Promise<GraphQLResponse | null> {
requestContext.metrics.responseCacheHit = false
/**
* Inject redis instance `__redis` and `__nodeFQCKeySet` to context,
* used by `@logCache`, `@purgeCache`,
* and `willSendResponse` below.
*/
requestContext.contextValue.__redis = options.redis
requestContext.contextValue.__nodeFQCKeySet = new Set()
if (!isGraphQLQuery(requestContext)) {
return null
}
async function cacheGet(
contextualCacheKeyFields: ContextualCacheKeyData
): Promise<GraphQLResponse | null> {
const cacheKeyData = {
...baseCacheKey!,
...contextualCacheKeyFields,
}
const key = generateCacheKey(requestContext, cacheKeyData)
const serializedValue = await redis.get(CACHE_KEY_PREFIX_FQC + key)
if (serializedValue === null) {
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 {
body: { kind: 'single', singleResult: { data: value.data } },
http: {
status: undefined,
headers: new HeaderMap(),
},
}
}
// 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
// We don't support caching incremental delivery responses (ie,
// responses that use @defer or @stream) now. (It might be useful to
// do so: after all, deferred responses might benefit the most from
// caching! But we don't right now.)
if (requestContext.response.body.kind !== 'single') {
return
}
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 { data, errors } = requestContext.response.body.singleResult
const policyIfCacheable =
requestContext.overallCachePolicy.policyIfCacheable()
if (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: ContextualCacheKeyData
): 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).
redis
.set(
CACHE_KEY_PREFIX_FQC + key,
serializedValue,
'EX',
policyIfCacheable.maxAge
)
.catch(logger.warn)
const { __nodeFQCKeySet, __redis } = requestContext.contextValue
if (__nodeFQCKeySet && __redis) {
recordNodeFQCMapping({
nodeFQCKeys: __nodeFQCKeySet,
fqcKey: key,
ttl: options.nodeFQCTTL,
redis: __redis,
})
}
}
const isPrivate = policyIfCacheable.scope === '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 " +
'@thematters/apollo-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,
})
}
},
}
},
}
}