Skip to content

Commit 4f30aba

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

File tree

6 files changed

+280
-19
lines changed

6 files changed

+280
-19
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: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ExecutionArgs,
66
getOperationAST,
77
GraphQLDirective,
8+
GraphQLSchema,
89
Kind,
910
print,
1011
TypeInfo,
@@ -30,6 +31,7 @@ import {
3031
mergeIncrementalResult,
3132
} from '@graphql-tools/utils';
3233
import type { Cache, CacheEntityRecord } from './cache.js';
34+
import { getScopeFromQuery } from './get-scope.js';
3335
import { hashSHA256 } from './hash-sha256.js';
3436
import { createInMemoryCache } from './in-memory-cache.js';
3537

@@ -47,6 +49,8 @@ export type BuildResponseCacheKeyFunction = (params: {
4749
sessionId: Maybe<string>;
4850
/** GraphQL Context */
4951
context: ExecutionArgs['contextValue'];
52+
/** Callback to get the scope */
53+
getScope: () => NonNullable<CacheControlDirective['scope']>;
5054
}) => Promise<string>;
5155

5256
export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string;
@@ -76,8 +80,8 @@ export type UseResponseCacheParameter<PluginContext extends Record<string, any>
7680
* In the unusual case where you actually want to cache introspection query operations,
7781
* you need to provide the value `{ 'Query.__schema': undefined }`.
7882
*/
79-
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
80-
scopePerSchemaCoordinate?: Record<string, 'PRIVATE' | 'PUBLIC' | undefined>;
83+
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
84+
scopePerSchemaCoordinate?: Record<string, CacheControlDirective['scope']>;
8185
/**
8286
* Allows to cache responses based on the resolved session id.
8387
* Return a unique value for each session.
@@ -215,11 +219,11 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
215219
ttlPerSchemaCoordinate,
216220
}: {
217221
invalidateViaMutation: boolean;
218-
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
222+
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
219223
},
220224
schema: any,
221225
idFieldByTypeName: Map<string, string>,
222-
): [DocumentNode, number | undefined] {
226+
): [DocumentNode, CacheControlDirective['maxAge']] {
223227
const typeInfo = new TypeInfo(schema);
224228
let ttl: number | undefined;
225229
const visitor: ASTVisitor = {
@@ -238,7 +242,7 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
238242
const parentType = typeInfo.getParentType();
239243
if (parentType) {
240244
const schemaCoordinate = `${parentType.name}.${fieldNode.name.value}`;
241-
const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate] as unknown;
245+
const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate];
242246
ttl = calculateTtl(maybeTtl, ttl);
243247
}
244248
},
@@ -279,20 +283,37 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
279283
return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl];
280284
});
281285

282-
type CacheControlDirective = {
286+
export type CacheControlDirective = {
283287
maxAge?: number;
284288
scope?: 'PUBLIC' | 'PRIVATE';
285289
};
286290

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,14 @@ 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-
}
339+
let schema: GraphQLSchema;
327340

328341
return {
329342
onSchemaChange({ schema: newSchema }) {
@@ -522,6 +535,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
522535
operationName: onExecuteParams.args.operationName,
523536
sessionId,
524537
context: onExecuteParams.args.contextValue,
538+
getScope: () => getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body),
525539
});
526540

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

packages/plugins/response-cache/test/response-cache.spec.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3363,6 +3363,91 @@ describe('useResponseCache', () => {
33633363
expect(spy).toHaveBeenCalledTimes(2);
33643364
});
33653365

3366+
['query', 'field', 'subfield'].forEach(type => {
3367+
it(`should return PRIVATE scope in buildResponseCacheKey when putting @cacheControl scope on ${type}`, async () => {
3368+
jest.useFakeTimers();
3369+
const spy = jest.fn(() => [
3370+
{
3371+
id: 1,
3372+
name: 'User 1',
3373+
comments: [
3374+
{
3375+
id: 1,
3376+
text: 'Comment 1 of User 1',
3377+
},
3378+
],
3379+
},
3380+
{
3381+
id: 2,
3382+
name: 'User 2',
3383+
comments: [
3384+
{
3385+
id: 2,
3386+
text: 'Comment 2 of User 2',
3387+
},
3388+
],
3389+
},
3390+
]);
3391+
3392+
const schema = makeExecutableSchema({
3393+
typeDefs: /* GraphQL */ `
3394+
${cacheControlDirective}
3395+
type Query {
3396+
users: [User!]! ${type === 'query' ? '@cacheControl(scope: PRIVATE)' : ''}
3397+
}
3398+
3399+
type User ${type === 'field' ? '@cacheControl(scope: PRIVATE)' : ''} {
3400+
id: ID!
3401+
name: String! ${type === 'subfield' ? '@cacheControl(scope: PRIVATE)' : ''}
3402+
comments: [Comment!]!
3403+
recentComment: Comment
3404+
}
3405+
3406+
type Comment {
3407+
id: ID!
3408+
text: String!
3409+
}
3410+
`,
3411+
resolvers: {
3412+
Query: {
3413+
users: spy,
3414+
},
3415+
},
3416+
});
3417+
3418+
const testInstance = createTestkit(
3419+
[
3420+
useResponseCache({
3421+
session: () => null,
3422+
buildResponseCacheKey: ({ getScope, ...rest }) => {
3423+
expect(getScope()).toEqual('PRIVATE');
3424+
return defaultBuildResponseCacheKey(rest);
3425+
},
3426+
ttl: 200,
3427+
}),
3428+
],
3429+
schema,
3430+
);
3431+
3432+
const query = /* GraphQL */ `
3433+
query test {
3434+
users {
3435+
id
3436+
name
3437+
comments {
3438+
id
3439+
text
3440+
}
3441+
}
3442+
}
3443+
`;
3444+
3445+
await testInstance.execute(query);
3446+
3447+
expect(spy).toHaveBeenCalledTimes(1);
3448+
});
3449+
});
3450+
33663451
it('should cache correctly for session with ttl being a valid number', async () => {
33673452
jest.useFakeTimers();
33683453
const spy = jest.fn(() => [

0 commit comments

Comments
 (0)