Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(response-cache): added getScope callback in buildResponseCacheKey #2202

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sour-cars-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/response-cache': minor
---

Added `getScope` callback in `buildResponseCacheKey` params
52 changes: 52 additions & 0 deletions packages/plugins/response-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
113 changes: 113 additions & 0 deletions packages/plugins/response-cache/src/get-scope.ts
Original file line number Diff line number Diff line change
@@ -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<SelectionNode> = [],
record: Record<string, any>,
) {
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<string, any>,
prefix?: string,
): Set<string> {
let fields: Set<string> = 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<string> {
const ast = parse(query);
let fields: Set<string> = new Set();

// Launch the field visitor
visit(ast, {
// Parse the fields of the root of query
Field: node => {
const record: Record<string, any> = {};
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<CacheControlDirective['scope']> => {
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 });
};
1 change: 1 addition & 0 deletions packages/plugins/response-cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
59 changes: 35 additions & 24 deletions packages/plugins/response-cache/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
DocumentNode,
ExecutionArgs,
getOperationAST,
GraphQLDirective,
GraphQLSchema,
Kind,
print,
TypeInfo,
Expand All @@ -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';

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

export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string;
Expand Down Expand Up @@ -76,8 +79,8 @@ export type UseResponseCacheParameter<PluginContext extends Record<string, any>
* In the unusual case where you actually want to cache introspection query operations,
* you need to provide the value `{ 'Query.__schema': undefined }`.
*/
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
scopePerSchemaCoordinate?: Record<string, 'PRIVATE' | 'PUBLIC' | undefined>;
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
scopePerSchemaCoordinate?: Record<string, CacheControlDirective['scope']>;
/**
* Allows to cache responses based on the resolved session id.
* Return a unique value for each session.
Expand Down Expand Up @@ -215,11 +218,11 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
ttlPerSchemaCoordinate,
}: {
invalidateViaMutation: boolean;
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
},
schema: any,
schema: GraphQLSchema,
idFieldByTypeName: Map<string, string>,
): [DocumentNode, number | undefined] {
): [DocumentNode, CacheControlDirective['maxAge']] {
const typeInfo = new TypeInfo(schema);
let ttl: number | undefined;
const visitor: ASTVisitor = {
Expand All @@ -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);
}
},
Expand Down Expand Up @@ -279,20 +282,38 @@ 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<string, CacheControlDirective['maxAge']> = {};
let scopePerSchemaCoordinate: Record<string, CacheControlDirective['scope']> = {};

export function isPrivate(
typeName: string,
data?: Record<string, NonNullable<CacheControlDirective['scope']>>,
): boolean {
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
return true;
}
return data
? Object.keys(data).some(
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
)
: false;
}

export function useResponseCache<PluginContext extends Record<string, any> = {}>({
cache = createInMemoryCache(),
ttl: globalTtl = Infinity,
session,
enabled,
ignoredTypes = [],
ttlPerType = {},
ttlPerSchemaCoordinate = {},
scopePerSchemaCoordinate = {},
ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {},
scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {},
idFields = ['id'],
invalidateViaMutation = true,
buildResponseCacheKey = defaultBuildResponseCacheKey,
Expand All @@ -308,22 +329,13 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
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<string, string>();
let schema: any;

function isPrivate(typeName: string, data: Record<string, unknown>): boolean {
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
return true;
}
return Object.keys(data).some(
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
);
}

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

const directive = schema.getDirective('cacheControl') as unknown as
| GraphQLDirective
| undefined;
const directive = schema.getDirective('cacheControl');

mapSchema(schema, {
...(directive && {
Expand Down Expand Up @@ -522,6 +532,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
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;
Expand Down