diff --git a/.changeset/sour-cars-hang.md b/.changeset/sour-cars-hang.md new file mode 100644 index 0000000000..b3191c495d --- /dev/null +++ b/.changeset/sour-cars-hang.md @@ -0,0 +1,5 @@ +--- +'@envelop/response-cache': minor +--- + +Added `getScope` callback in `buildResponseCacheKey` params diff --git a/packages/plugins/response-cache/README.md b/packages/plugins/response-cache/README.md index ae22f23ea6..8dee145136 100644 --- a/packages/plugins/response-cache/README.md +++ b/packages/plugins/response-cache/README.md @@ -818,3 +818,55 @@ mutation SetNameMutation { } } ``` + +#### Get scope of the query + +Useful for building a cache key that is shared across all sessions when `PUBLIC`. + +```ts +import jsonStableStringify from 'fast-json-stable-stringify' +import { execute, parse, subscribe, validate } from 'graphql' +import { envelop } from '@envelop/core' +import { hashSHA256, useResponseCache } from '@envelop/response-cache' + +const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, + plugins: [ + // ... other plugins ... + useResponseCache({ + ttl: 2000, + session: request => getSessionId(request), + buildResponseCacheKey: ({ + getScope, + sessionId, + documentString, + operationName, + variableValues + }) => + // Use `getScope()` to put a unique key for every session when `PUBLIC` + hashSHA256( + [ + getScope() === 'PUBLIC' ? 'PUBLIC' : sessionId, + documentString, + operationName ?? '', + jsonStableStringify(variableValues ?? {}) + ].join('|') + ), + scopePerSchemaCoordinate: { + // Set scope for an entire query + 'Query.getProfile': 'PRIVATE', + // Set scope for an entire type + PrivateProfile: 'PRIVATE', + // Set scope for a single field + 'Profile.privateData': 'PRIVATE' + } + }) + ] +}) +``` + +> Note: The use of this callback will increase the ram usage since it memoizes the scope for each +> query in a weak map. diff --git a/packages/plugins/response-cache/src/get-scope.ts b/packages/plugins/response-cache/src/get-scope.ts new file mode 100644 index 0000000000..5178377ea2 --- /dev/null +++ b/packages/plugins/response-cache/src/get-scope.ts @@ -0,0 +1,113 @@ +import { + FieldNode, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + Kind, + parse, + SelectionNode, + visit, +} from 'graphql'; +import { memoize1 } from '@graphql-tools/utils'; +import { CacheControlDirective, isPrivate } from './plugin'; + +/** Parse the selected query fields */ +function parseSelections( + selections: ReadonlyArray = [], + record: Record, +) { + for (const selection of selections) { + if (selection.kind === Kind.FIELD) { + record[selection.name.value] = {}; + parseSelections(selection.selectionSet?.selections, record[selection.name.value]); + } + } +} + +/** Iterate over record and parse its fields with schema type */ +function parseRecordWithSchemaType( + type: GraphQLOutputType, + record: Record, + prefix?: string, +): Set { + let fields: Set = new Set(); + if (type instanceof GraphQLNonNull || type instanceof GraphQLList) { + fields = new Set([...fields, ...parseRecordWithSchemaType(type.ofType, record, prefix)]); + } + + if (type instanceof GraphQLObjectType) { + const newPrefixes = [...(prefix ?? []), type.name]; + fields.add(newPrefixes.join('.')); + + const typeFields = type.getFields(); + for (const key of Object.keys(record)) { + const field = typeFields[key]; + if (!field) { + continue; + } + + fields.add([...newPrefixes, field.name].join('.')); + if (Object.keys(record[key]).length > 0) { + fields = new Set([...fields, ...parseRecordWithSchemaType(field.type, record[key])]); + } + } + } + + return fields; +} + +function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Set { + const ast = parse(query); + let fields: Set = new Set(); + + // Launch the field visitor + visit(ast, { + // Parse the fields of the root of query + Field: node => { + const record: Record = {}; + const queryFields = schema.getQueryType()?.getFields()[node.name.value]; + + if (queryFields) { + record[node.name.value] = {}; + parseSelections(node.selectionSet?.selections, record[node.name.value]); + + fields.add(`Query.${node.name.value}`); + fields = new Set([ + ...fields, + ...parseRecordWithSchemaType(queryFields.type, record[node.name.value]), + ]); + } + }, + // And each fragment + FragmentDefinition: fragment => { + const type = fragment.typeCondition.name.value; + fields = new Set([ + ...fields, + ...( + fragment.selectionSet.selections.filter(({ kind }) => kind === Kind.FIELD) as FieldNode[] + ).map(({ name: { value } }) => `${type}.${value}`), + ]); + }, + }); + + return fields; +} + +export const getScopeFromQuery = ( + schema: GraphQLSchema, + query: string, +): NonNullable => { + const fn = memoize1(({ query }: { query: string }) => { + const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query); + + for (const coordinate of schemaCoordinates) { + if (isPrivate(coordinate)) { + return 'PRIVATE'; + } + } + return 'PUBLIC'; + }); + return fn({ query }); +}; diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts index 2d6d23a362..69663dd532 100644 --- a/packages/plugins/response-cache/src/index.ts +++ b/packages/plugins/response-cache/src/index.ts @@ -2,3 +2,4 @@ export * from './in-memory-cache.js'; export * from './plugin.js'; export * from './cache.js'; export * from './hash-sha256.js'; +export * from './get-scope.js'; diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index 287e623290..5f4c2eb331 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -4,7 +4,7 @@ import { DocumentNode, ExecutionArgs, getOperationAST, - GraphQLDirective, + GraphQLSchema, Kind, print, TypeInfo, @@ -30,6 +30,7 @@ import { mergeIncrementalResult, } from '@graphql-tools/utils'; import type { Cache, CacheEntityRecord } from './cache.js'; +import { getScopeFromQuery } from './get-scope.js'; import { hashSHA256 } from './hash-sha256.js'; import { createInMemoryCache } from './in-memory-cache.js'; @@ -47,6 +48,8 @@ export type BuildResponseCacheKeyFunction = (params: { sessionId: Maybe; /** GraphQL Context */ context: ExecutionArgs['contextValue']; + /** Callback to get the scope */ + getScope: () => NonNullable; }) => Promise; export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string; @@ -76,8 +79,8 @@ export type UseResponseCacheParameter * In the unusual case where you actually want to cache introspection query operations, * you need to provide the value `{ 'Query.__schema': undefined }`. */ - ttlPerSchemaCoordinate?: Record; - scopePerSchemaCoordinate?: Record; + ttlPerSchemaCoordinate?: Record; + scopePerSchemaCoordinate?: Record; /** * Allows to cache responses based on the resolved session id. * Return a unique value for each session. @@ -215,11 +218,11 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument( ttlPerSchemaCoordinate, }: { invalidateViaMutation: boolean; - ttlPerSchemaCoordinate?: Record; + ttlPerSchemaCoordinate?: Record; }, - schema: any, + schema: GraphQLSchema, idFieldByTypeName: Map, -): [DocumentNode, number | undefined] { +): [DocumentNode, CacheControlDirective['maxAge']] { const typeInfo = new TypeInfo(schema); let ttl: number | undefined; const visitor: ASTVisitor = { @@ -238,7 +241,7 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument( const parentType = typeInfo.getParentType(); if (parentType) { const schemaCoordinate = `${parentType.name}.${fieldNode.name.value}`; - const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate] as unknown; + const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate]; ttl = calculateTtl(maybeTtl, ttl); } }, @@ -279,11 +282,29 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument( return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl]; }); -type CacheControlDirective = { +export type CacheControlDirective = { maxAge?: number; scope?: 'PUBLIC' | 'PRIVATE'; }; +export let schema: GraphQLSchema; +let ttlPerSchemaCoordinate: Record = {}; +let scopePerSchemaCoordinate: Record = {}; + +export function isPrivate( + typeName: string, + data?: Record>, +): boolean { + if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { + return true; + } + return data + ? Object.keys(data).some( + fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE', + ) + : false; +} + export function useResponseCache = {}>({ cache = createInMemoryCache(), ttl: globalTtl = Infinity, @@ -291,8 +312,8 @@ export function useResponseCache = {}> enabled, ignoredTypes = [], ttlPerType = {}, - ttlPerSchemaCoordinate = {}, - scopePerSchemaCoordinate = {}, + ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {}, + scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {}, idFields = ['id'], invalidateViaMutation = true, buildResponseCacheKey = defaultBuildResponseCacheKey, @@ -308,22 +329,13 @@ export function useResponseCache = {}> enabled = enabled ? memoize1(enabled) : enabled; // never cache Introspections - ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate }; + ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate }; const documentMetadataOptions = { queries: { invalidateViaMutation, ttlPerSchemaCoordinate }, mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation }; + scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate }; const idFieldByTypeName = new Map(); - let schema: any; - - function isPrivate(typeName: string, data: Record): boolean { - if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { - return true; - } - return Object.keys(data).some( - fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE', - ); - } return { onSchemaChange({ schema: newSchema }) { @@ -332,9 +344,7 @@ export function useResponseCache = {}> } schema = newSchema; - const directive = schema.getDirective('cacheControl') as unknown as - | GraphQLDirective - | undefined; + const directive = schema.getDirective('cacheControl'); mapSchema(schema, { ...(directive && { @@ -522,6 +532,7 @@ export function useResponseCache = {}> operationName: onExecuteParams.args.operationName, sessionId, context: onExecuteParams.args.contextValue, + getScope: () => getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body), }); const cachedResponse = (await cache.get(cacheKey)) as ResponseCacheExecutionResult; diff --git a/packages/plugins/response-cache/test/response-cache.spec.ts b/packages/plugins/response-cache/test/response-cache.spec.ts index 61abd5ef35..a9d60e4c96 100644 --- a/packages/plugins/response-cache/test/response-cache.spec.ts +++ b/packages/plugins/response-cache/test/response-cache.spec.ts @@ -3124,7 +3124,7 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); - it('should not cache response with a type with a PRIVATE scope for request without session using @cachControl directive', async () => { + it('should not cache response with a type with a PRIVATE scope for request without session using @cacheControl directive', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [ { @@ -3284,7 +3284,7 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); - it('should not cache response with a field with PRIVATE scope for request without session using @cachControl directive', async () => { + it('should not cache response with a field with PRIVATE scope for request without session using @cacheControl directive', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [ { @@ -3363,6 +3363,178 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); + ['query', 'field', 'subfield'].forEach(type => { + it(`should return PRIVATE scope in buildResponseCacheKey when putting @cacheControl scope on ${type}`, async () => { + jest.useFakeTimers(); + const spy = jest.fn(() => [ + { + id: 1, + name: 'User 1', + comments: [ + { + id: 1, + text: 'Comment 1 of User 1', + }, + ], + }, + { + id: 2, + name: 'User 2', + comments: [ + { + id: 2, + text: 'Comment 2 of User 2', + }, + ], + }, + ]); + + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + ${cacheControlDirective} + type Query { + users: [User!]! ${type === 'query' ? '@cacheControl(scope: PRIVATE)' : ''} + } + + type User ${type === 'field' ? '@cacheControl(scope: PRIVATE)' : ''} { + id: ID! + name: String! ${type === 'subfield' ? '@cacheControl(scope: PRIVATE)' : ''} + comments: [Comment!]! + recentComment: Comment + } + + type Comment { + id: ID! + text: String! + } + `, + resolvers: { + Query: { + users: spy, + }, + }, + }); + + const testInstance = createTestkit( + [ + useResponseCache({ + session: () => null, + buildResponseCacheKey: ({ getScope, ...rest }) => { + expect(getScope()).toEqual('PRIVATE'); + return defaultBuildResponseCacheKey(rest); + }, + ttl: 200, + }), + ], + schema, + ); + + const query = /* GraphQL */ ` + query test { + users { + id + name + comments { + id + text + } + } + } + `; + + await testInstance.execute(query); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + it('should return PRIVATE scope in buildResponseCacheKey even when requesting property from a fragment', async () => { + jest.useFakeTimers(); + const spy = jest.fn(() => [ + { + id: 1, + name: 'User 1', + comments: [ + { + id: 1, + text: 'Comment 1 of User 1', + }, + ], + }, + { + id: 2, + name: 'User 2', + comments: [ + { + id: 2, + text: 'Comment 2 of User 2', + }, + ], + }, + ]); + + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + ${cacheControlDirective} + type Query { + users: [User!]! + } + + type User { + id: ID! + name: String! @cacheControl(scope: PRIVATE) + comments: [Comment!]! + recentComment: Comment + } + + type Comment { + id: ID! + text: String! + } + `, + resolvers: { + Query: { + users: spy, + }, + }, + }); + + const testInstance = createTestkit( + [ + useResponseCache({ + session: () => null, + buildResponseCacheKey: ({ getScope, ...rest }) => { + expect(getScope()).toEqual('PRIVATE'); + return defaultBuildResponseCacheKey(rest); + }, + ttl: 200, + }), + ], + schema, + ); + + const query = /* GraphQL */ ` + query test { + users { + ...user + } + } + + fragment user on User { + id + name + comments { + id + text + } + } + `; + + await testInstance.execute(query); + + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should cache correctly for session with ttl being a valid number', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [