Skip to content

Commit a5468bb

Browse files
committed
feat(response-cache): added getScope callback in buildResponseCacheKey
1 parent d7f6da0 commit a5468bb

File tree

7 files changed

+285
-27
lines changed

7 files changed

+285
-27
lines changed

.changeset/sour-cars-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@envelop/response-cache': minor
3+
---
4+
5+
Added `getScope` callback in `buildResponseCacheKey` params

packages/plugins/response-cache/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,3 +818,55 @@ mutation SetNameMutation {
818818
}
819819
}
820820
```
821+
822+
#### Get scope of the query
823+
824+
Useful for building a cache key that is shared across all sessions when `PUBLIC`.
825+
826+
```ts
827+
import jsonStableStringify from 'fast-json-stable-stringify'
828+
import { execute, parse, subscribe, validate } from 'graphql'
829+
import { envelop } from '@envelop/core'
830+
import { hashSHA256, useResponseCache } from '@envelop/response-cache'
831+
832+
const getEnveloped = envelop({
833+
parse,
834+
validate,
835+
execute,
836+
subscribe,
837+
plugins: [
838+
// ... other plugins ...
839+
useResponseCache({
840+
ttl: 2000,
841+
session: request => getSessionId(request),
842+
buildResponseCacheKey: ({
843+
getScope,
844+
sessionId,
845+
documentString,
846+
operationName,
847+
variableValues
848+
}) =>
849+
// Use `getScope()` to put a unique key for every session when `PUBLIC`
850+
hashSHA256(
851+
[
852+
getScope() === 'PUBLIC' ? 'PUBLIC' : sessionId,
853+
documentString,
854+
operationName ?? '',
855+
jsonStableStringify(variableValues ?? {})
856+
].join('|')
857+
),
858+
scopePerSchemaCoordinate: {
859+
// Set scope for an entire query
860+
'Query.getProfile': 'PRIVATE',
861+
// Set scope for an entire type
862+
PrivateProfile: 'PRIVATE',
863+
// Set scope for a single field
864+
'Profile.privateData': 'PRIVATE'
865+
}
866+
})
867+
]
868+
})
869+
```
870+
871+
> Note: The use of this callback will increase the ram usage since it memoizes the scope for each
872+
> query in a weak map.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
FieldNode,
3+
GraphQLList,
4+
GraphQLNonNull,
5+
GraphQLObjectType,
6+
GraphQLOutputType,
7+
GraphQLSchema,
8+
Kind,
9+
parse,
10+
SelectionNode,
11+
visit,
12+
} from 'graphql';
13+
import { memoize1 } from '@graphql-tools/utils';
14+
import { CacheControlDirective, isPrivate } from './plugin';
15+
16+
/** Parse the selected query fields */
17+
function parseSelections(
18+
selections: ReadonlyArray<SelectionNode> = [],
19+
record: Record<string, any>,
20+
) {
21+
for (const selection of selections) {
22+
if (selection.kind === Kind.FIELD) {
23+
record[selection.name.value] = {};
24+
parseSelections(selection.selectionSet?.selections, record[selection.name.value]);
25+
}
26+
}
27+
}
28+
29+
/** Iterate over record and parse its fields with schema type */
30+
function parseRecordWithSchemaType(
31+
type: GraphQLOutputType,
32+
record: Record<string, any>,
33+
prefix?: string,
34+
): Set<string> {
35+
let fields: Set<string> = new Set();
36+
if (type instanceof GraphQLNonNull || type instanceof GraphQLList) {
37+
fields = new Set([...fields, ...parseRecordWithSchemaType(type.ofType, record, prefix)]);
38+
}
39+
40+
if (type instanceof GraphQLObjectType) {
41+
const newPrefixes = [...(prefix ?? []), type.name];
42+
fields.add(newPrefixes.join('.'));
43+
44+
const typeFields = type.getFields();
45+
for (const key of Object.keys(record)) {
46+
const field = typeFields[key];
47+
if (!field) {
48+
continue;
49+
}
50+
51+
fields.add([...newPrefixes, field.name].join('.'));
52+
if (Object.keys(record[key]).length > 0) {
53+
fields = new Set([...fields, ...parseRecordWithSchemaType(field.type, record[key])]);
54+
}
55+
}
56+
}
57+
58+
return fields;
59+
}
60+
61+
function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Set<string> {
62+
const ast = parse(query);
63+
let fields: Set<string> = new Set();
64+
65+
const visitField = (node: FieldNode) => {
66+
const record: Record<string, any> = {};
67+
const queryFields = schema.getQueryType()?.getFields()[node.name.value];
68+
69+
if (queryFields) {
70+
record[node.name.value] = {};
71+
parseSelections(node.selectionSet?.selections, record[node.name.value]);
72+
73+
fields.add(`Query.${node.name.value}`);
74+
fields = new Set([
75+
...fields,
76+
...parseRecordWithSchemaType(queryFields.type, record[node.name.value]),
77+
]);
78+
}
79+
};
80+
81+
// Launch the field visitor
82+
visit(ast, {
83+
Field: visitField,
84+
});
85+
86+
return fields;
87+
}
88+
89+
export const getScopeFromQuery = (
90+
schema: GraphQLSchema,
91+
query: string,
92+
): NonNullable<CacheControlDirective['scope']> => {
93+
const fn = memoize1(({ query }: { query: string }) => {
94+
const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query);
95+
96+
for (const coordinate of schemaCoordinates) {
97+
if (isPrivate(coordinate)) {
98+
return 'PRIVATE';
99+
}
100+
}
101+
return 'PUBLIC';
102+
});
103+
return fn({ query });
104+
};

packages/plugins/response-cache/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './in-memory-cache.js';
22
export * from './plugin.js';
33
export * from './cache.js';
44
export * from './hash-sha256.js';
5+
export * from './get-scope.js';

packages/plugins/response-cache/src/plugin.ts

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
DocumentNode,
55
ExecutionArgs,
66
getOperationAST,
7-
GraphQLDirective,
7+
GraphQLSchema,
88
Kind,
99
print,
1010
TypeInfo,
@@ -30,6 +30,7 @@ import {
3030
mergeIncrementalResult,
3131
} from '@graphql-tools/utils';
3232
import type { Cache, CacheEntityRecord } from './cache.js';
33+
import { getScopeFromQuery } from './get-scope.js';
3334
import { hashSHA256 } from './hash-sha256.js';
3435
import { createInMemoryCache } from './in-memory-cache.js';
3536

@@ -47,6 +48,8 @@ export type BuildResponseCacheKeyFunction = (params: {
4748
sessionId: Maybe<string>;
4849
/** GraphQL Context */
4950
context: ExecutionArgs['contextValue'];
51+
/** Callback to get the scope */
52+
getScope: () => NonNullable<CacheControlDirective['scope']>;
5053
}) => Promise<string>;
5154

5255
export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string;
@@ -76,8 +79,8 @@ export type UseResponseCacheParameter<PluginContext extends Record<string, any>
7679
* In the unusual case where you actually want to cache introspection query operations,
7780
* you need to provide the value `{ 'Query.__schema': undefined }`.
7881
*/
79-
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
80-
scopePerSchemaCoordinate?: Record<string, 'PRIVATE' | 'PUBLIC' | undefined>;
82+
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
83+
scopePerSchemaCoordinate?: Record<string, CacheControlDirective['scope']>;
8184
/**
8285
* Allows to cache responses based on the resolved session id.
8386
* Return a unique value for each session.
@@ -215,11 +218,11 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
215218
ttlPerSchemaCoordinate,
216219
}: {
217220
invalidateViaMutation: boolean;
218-
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
221+
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
219222
},
220-
schema: any,
223+
schema: GraphQLSchema,
221224
idFieldByTypeName: Map<string, string>,
222-
): [DocumentNode, number | undefined] {
225+
): [DocumentNode, CacheControlDirective['maxAge']] {
223226
const typeInfo = new TypeInfo(schema);
224227
let ttl: number | undefined;
225228
const visitor: ASTVisitor = {
@@ -238,7 +241,7 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
238241
const parentType = typeInfo.getParentType();
239242
if (parentType) {
240243
const schemaCoordinate = `${parentType.name}.${fieldNode.name.value}`;
241-
const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate] as unknown;
244+
const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate];
242245
ttl = calculateTtl(maybeTtl, ttl);
243246
}
244247
},
@@ -279,20 +282,38 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
279282
return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl];
280283
});
281284

282-
type CacheControlDirective = {
285+
export type CacheControlDirective = {
283286
maxAge?: number;
284287
scope?: 'PUBLIC' | 'PRIVATE';
285288
};
286289

290+
export let schema: GraphQLSchema;
291+
let ttlPerSchemaCoordinate: Record<string, CacheControlDirective['maxAge']> = {};
292+
let scopePerSchemaCoordinate: Record<string, CacheControlDirective['scope']> = {};
293+
294+
export function isPrivate(
295+
typeName: string,
296+
data?: Record<string, NonNullable<CacheControlDirective['scope']>>,
297+
): boolean {
298+
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
299+
return true;
300+
}
301+
return data
302+
? Object.keys(data).some(
303+
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
304+
)
305+
: false;
306+
}
307+
287308
export function useResponseCache<PluginContext extends Record<string, any> = {}>({
288309
cache = createInMemoryCache(),
289310
ttl: globalTtl = Infinity,
290311
session,
291312
enabled,
292313
ignoredTypes = [],
293314
ttlPerType = {},
294-
ttlPerSchemaCoordinate = {},
295-
scopePerSchemaCoordinate = {},
315+
ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {},
316+
scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {},
296317
idFields = ['id'],
297318
invalidateViaMutation = true,
298319
buildResponseCacheKey = defaultBuildResponseCacheKey,
@@ -308,22 +329,13 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
308329
enabled = enabled ? memoize1(enabled) : enabled;
309330

310331
// never cache Introspections
311-
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate };
332+
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate };
312333
const documentMetadataOptions = {
313334
queries: { invalidateViaMutation, ttlPerSchemaCoordinate },
314335
mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation
315336
};
337+
scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate };
316338
const idFieldByTypeName = new Map<string, string>();
317-
let schema: any;
318-
319-
function isPrivate(typeName: string, data: Record<string, unknown>): boolean {
320-
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
321-
return true;
322-
}
323-
return Object.keys(data).some(
324-
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
325-
);
326-
}
327339

328340
return {
329341
onSchemaChange({ schema: newSchema }) {
@@ -332,9 +344,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
332344
}
333345
schema = newSchema;
334346

335-
const directive = schema.getDirective('cacheControl') as unknown as
336-
| GraphQLDirective
337-
| undefined;
347+
const directive = schema.getDirective('cacheControl');
338348

339349
mapSchema(schema, {
340350
...(directive && {
@@ -522,6 +532,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
522532
operationName: onExecuteParams.args.operationName,
523533
sessionId,
524534
context: onExecuteParams.args.contextValue,
535+
getScope: () => getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body),
525536
});
526537

527538
const cachedResponse = (await cache.get(cacheKey)) as ResponseCacheExecutionResult;

0 commit comments

Comments
 (0)