diff --git a/.changeset/rich-kiwis-stand.md b/.changeset/rich-kiwis-stand.md new file mode 100644 index 0000000000..240e8ba43d --- /dev/null +++ b/.changeset/rich-kiwis-stand.md @@ -0,0 +1,5 @@ +--- +'@envelop/response-cache': minor +--- + +Add `extras` function to `BuildResponseCacheKeyFunction` to get computed scope diff --git a/packages/plugins/response-cache/README.md b/packages/plugins/response-cache/README.md index fa2ee43715..2eed254098 100644 --- a/packages/plugins/response-cache/README.md +++ b/packages/plugins/response-cache/README.md @@ -863,3 +863,67 @@ mutation SetNameMutation { } } ``` + +#### Get scope of the query + +Useful for building a cache with more flexibility (e.g. generate a 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 schema = buildSchema(/* GraphQL */ ` + ${cacheControlDirective} + type PrivateProfile @cacheControl(scope: PRIVATE) { + # ... + } + + type Profile { + privateData: String @cacheControl(scope: PRIVATE) + } +`) + +const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, + plugins: [ + // ... other plugins ... + useResponseCache({ + ttl: 2000, + session: request => getSessionId(request), + buildResponseCacheKey: ({ + sessionId, + documentString, + operationName, + variableValues, + extras + }) => + hashSHA256( + [ + // Use it to put a unique key for every session when `PUBLIC` + extras(schema).scope === '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..d5c2ba8083 --- /dev/null +++ b/packages/plugins/response-cache/src/get-scope.ts @@ -0,0 +1,156 @@ +import { + FieldNode, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + Kind, + parse, + SelectionNode, + visit, +} from 'graphql'; +import { LRUCache } from 'lru-cache'; +import { isPrivate, type CacheControlDirective } from './plugin'; + +/** Parse the selected query fields */ +function parseSelections(selections: readonly SelectionNode[] = [], 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 = 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 = 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 type Scope = { + scope: NonNullable; + metadata?: { privateProperty?: string; hitCache?: boolean }; +}; + +const scopeCachePerSchema = new WeakMap>(); + +export type GetScopeFromQueryOptions = { + includeExtensionMetadata?: boolean; + sizePerSchema?: number; +}; + +export const getScopeFromQuery = ( + schema: GraphQLSchema, + query: string, + options?: GetScopeFromQueryOptions, +): Scope => { + if (!scopeCachePerSchema.has(schema)) { + scopeCachePerSchema.set( + schema, + new LRUCache({ + max: options?.sizePerSchema ?? 1000, + }), + ); + } + + const cache = scopeCachePerSchema.get(schema); + const cachedScope = cache?.get(query); + + if (cachedScope) + return { + ...cachedScope, + ...(options?.includeExtensionMetadata + ? { metadata: { ...cachedScope.metadata, hitCache: true } } + : {}), + }; + + function getScope() { + const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query); + + for (const coordinate of schemaCoordinates) { + if (isPrivate(coordinate)) { + return { + scope: 'PRIVATE' as const, + ...(options?.includeExtensionMetadata + ? { metadata: { privateProperty: coordinate } } + : {}), + }; + } + } + + return { + scope: 'PUBLIC' as const, + ...(options?.includeExtensionMetadata ? { metadata: {} } : {}), + }; + } + + const scope = getScope(); + cache?.set(query, scope); + + return scope; +}; 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 679c4eaeb3..ffe064ffcf 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -1,10 +1,11 @@ -import jsonStableStringify from 'fast-json-stable-stringify'; +import stringify from 'fast-json-stable-stringify'; import { ASTVisitor, DocumentNode, ExecutionArgs, getOperationAST, GraphQLDirective, + GraphQLSchema, GraphQLType, isListType, isNonNullType, @@ -35,6 +36,7 @@ import { } from '@graphql-tools/utils'; import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers'; import type { Cache, CacheEntityRecord } from './cache.js'; +import { getScopeFromQuery, GetScopeFromQueryOptions, Scope } from './get-scope.js'; import { hashSHA256 } from './hash-sha256.js'; import { createInMemoryCache } from './in-memory-cache.js'; @@ -52,6 +54,8 @@ export type BuildResponseCacheKeyFunction = (params: { sessionId: Maybe; /** GraphQL Context */ context: ExecutionArgs['contextValue']; + /** Extras of the query (won't be computed if not requested) */ + extras: (schema: GraphQLSchema) => Scope; }) => MaybePromise; export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string; @@ -171,7 +175,7 @@ export const defaultBuildResponseCacheKey = (params: { [ params.documentString, params.operationName ?? '', - jsonStableStringify(params.variableValues ?? {}), + stringify(params.variableValues ?? {}), params.sessionId ?? '', ].join('|'), ); @@ -295,11 +299,26 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument( return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl]; }); -type CacheControlDirective = { +export type CacheControlDirective = { maxAge?: number; scope?: 'PUBLIC' | 'PRIVATE'; }; +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, @@ -307,8 +326,8 @@ export function useResponseCache = {}> enabled, ignoredTypes = [], ttlPerType, - ttlPerSchemaCoordinate = {}, - scopePerSchemaCoordinate = {}, + ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {}, + scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {}, idFields = ['id'], invalidateViaMutation = true, buildResponseCacheKey = defaultBuildResponseCacheKey, @@ -326,7 +345,7 @@ export function useResponseCache = {}> enabled = enabled ? memoize1(enabled) : enabled; // never cache Introspections - ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate }; + ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate }; if (ttlPerType) { // eslint-disable-next-line no-console console.warn( @@ -341,17 +360,8 @@ export function useResponseCache = {}> 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 }) { @@ -564,6 +574,14 @@ export function useResponseCache = {}> operationName: onExecuteParams.args.operationName, sessionId, context: onExecuteParams.args.contextValue, + extras: ( + schema: GraphQLSchema, + options?: Omit, + ) => + getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body, { + ...options, + includeExtensionMetadata, + }), }), cacheKey => { const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); diff --git a/packages/plugins/response-cache/test/response-cache.spec.ts b/packages/plugins/response-cache/test/response-cache.spec.ts index 14d2e6984d..5efc81a50b 100644 --- a/packages/plugins/response-cache/test/response-cache.spec.ts +++ b/packages/plugins/response-cache/test/response-cache.spec.ts @@ -3285,7 +3285,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(() => [ { @@ -3445,7 +3445,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(() => [ { @@ -3524,6 +3524,196 @@ 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, + }, + }, + }); + + function getPrivateProperty() { + if (type === 'query') return 'Query.users'; + if (type === 'field') return 'User'; + return 'User.name'; + } + + const testInstance = createTestkit( + [ + useResponseCache({ + session: () => null, + includeExtensionMetadata: true, + buildResponseCacheKey: ({ extras, ...rest }) => { + const { scope, metadata } = extras(schema); + expect(scope).toEqual('PRIVATE'); + expect(metadata?.privateProperty).toEqual(getPrivateProperty()); + 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, + }, + }, + }); + + let multipleCalls = false; + + const testInstance = createTestkit( + [ + useResponseCache({ + session: () => null, + includeExtensionMetadata: true, + buildResponseCacheKey: ({ extras, ...rest }) => { + const { scope, metadata } = extras(schema); + expect(scope).toEqual('PRIVATE'); + expect(metadata?.privateProperty).toEqual('User.name'); + expect(metadata?.hitCache).toEqual(multipleCalls ? true : undefined); + 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); + + multipleCalls = true; + await testInstance.execute(query); + expect(spy).toHaveBeenCalledTimes(2); + }); + it('should cache correctly for session with ttl being a valid number', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [