UNPKG

@subsquid/apollo-server-core

Version:
351 lines (320 loc) 13.2 kB
import type { CacheAnnotation, CacheHint } from 'apollo-server-types'; import type { CacheScope } from 'apollo-server-types'; import { DirectiveNode, getNamedType, GraphQLCompositeType, GraphQLField, isCompositeType, isInterfaceType, isObjectType, responsePathAsArray, } from 'graphql'; import { newCachePolicy } from '../../cachePolicy'; import type { InternalApolloServerPlugin } from '../../internalPlugin'; import LRUCache from 'lru-cache'; export interface ApolloServerPluginCacheControlOptions { /** * All root fields and fields returning objects or interfaces have this value * for `maxAge` unless they set a cache hint with a non-undefined `maxAge` * using `@cacheControl` or `setCacheHint`. The default is 0, which means "not * cacheable". (That is: if you don't set `defaultMaxAge`, then every root * field in your operation and every field with sub-fields must have a cache * hint or the overall operation will not be cacheable.) */ defaultMaxAge?: number; /** * Determines whether to set the `Cache-Control` HTTP header on cacheable * responses with no errors. The default is true. */ calculateHttpHeaders?: boolean; // For testing only. __testing__cacheHints?: Map<string, CacheHint>; } export function ApolloServerPluginCacheControl( options: ApolloServerPluginCacheControlOptions = Object.create(null), ): InternalApolloServerPlugin { const typeAnnotationCache = new LRUCache< GraphQLCompositeType, CacheAnnotation >(); const fieldAnnotationCache = new LRUCache< GraphQLField<unknown, unknown>, CacheAnnotation >(); function memoizedCacheAnnotationFromType( t: GraphQLCompositeType, ): CacheAnnotation { const existing = typeAnnotationCache.get(t); if (existing) { return existing; } const annotation = cacheAnnotationFromType(t); typeAnnotationCache.set(t, annotation); return annotation; } function memoizedCacheAnnotationFromField( field: GraphQLField<unknown, unknown>, ): CacheAnnotation { const existing = fieldAnnotationCache.get(field); if (existing) { return existing; } const annotation = cacheAnnotationFromField(field); fieldAnnotationCache.set(field, annotation); return annotation; } return { __internal_plugin_id__() { return 'CacheControl'; }, async serverWillStart({ schema }) { // Set the size of the caches to be equal to the number of composite types // and fields in the schema respectively. This generally means that the // cache will always have room for all the cache hints in the active // schema but we won't have a memory leak as schemas are replaced in a // gateway. (Once we're comfortable breaking compatibility with // versions of Gateway older than 0.35.0, we should also run this code // from a schemaDidLoadOrUpdate instead of serverWillStart. Using // schemaDidLoadOrUpdate throws when combined with old gateways.) typeAnnotationCache.max = Object.values(schema.getTypeMap()).filter( isCompositeType, ).length; fieldAnnotationCache.max = Object.values(schema.getTypeMap()) .filter(isObjectType) .flatMap((t) => Object.values(t.getFields())).length + Object.values(schema.getTypeMap()) .filter(isInterfaceType) .flatMap((t) => Object.values(t.getFields())).length; return undefined; }, async requestDidStart(requestContext) { const defaultMaxAge: number = options.defaultMaxAge ?? 0; const calculateHttpHeaders = options.calculateHttpHeaders ?? true; const { __testing__cacheHints } = options; return { async executionDidStart() { // Did something set the overall cache policy before we've even // started? If so, consider that as an override and don't touch it. // Just put set up fake `info.cacheControl` objects and otherwise // don't track cache policy. // // (This doesn't happen in practice using the core plugins: the main // use case for restricting overallCachePolicy outside of this plugin // is apollo-server-plugin-response-cache, but when it sets the policy // we never get to execution at all.) if (isRestricted(requestContext.overallCachePolicy)) { // This is "fake" in the sense that it never actually affects // requestContext.overallCachePolicy. const fakeFieldPolicy = newCachePolicy(); return { willResolveField({ info }) { info.cacheControl = { setCacheHint: (dynamicHint: CacheHint) => { fakeFieldPolicy.replace(dynamicHint); }, cacheHint: fakeFieldPolicy, cacheHintFromType: memoizedCacheAnnotationFromType, }; }, }; } return { willResolveField({ info }) { const fieldPolicy = newCachePolicy(); let inheritMaxAge = false; // If this field's resolver returns an object/interface/union // (maybe wrapped in list/non-null), look for hints on that return // type. const targetType = getNamedType(info.returnType); if (isCompositeType(targetType)) { const typeAnnotation = memoizedCacheAnnotationFromType(targetType); fieldPolicy.replace(typeAnnotation); inheritMaxAge = !!typeAnnotation.inheritMaxAge; } // Look for hints on the field itself (on its parent type), taking // precedence over previously calculated hints. const fieldAnnotation = memoizedCacheAnnotationFromField( info.parentType.getFields()[info.fieldName], ); // Note that specifying `@cacheControl(inheritMaxAge: true)` on a // field whose return type defines a `maxAge` gives precedence to // the type's `maxAge`. (Perhaps this should be some sort of // error.) if ( fieldAnnotation.inheritMaxAge && fieldPolicy.maxAge === undefined ) { inheritMaxAge = true; // Handle `@cacheControl(inheritMaxAge: true, scope: PRIVATE)`. // (We ignore any specified `maxAge`; perhaps it should be some // sort of error.) if (fieldAnnotation.scope) { fieldPolicy.replace({ scope: fieldAnnotation.scope }); } } else { fieldPolicy.replace(fieldAnnotation); } info.cacheControl = { setCacheHint: (dynamicHint: CacheHint) => { fieldPolicy.replace(dynamicHint); }, cacheHint: fieldPolicy, cacheHintFromType: memoizedCacheAnnotationFromType, }; // When the resolver is done, call restrict once. By calling // restrict after the resolver instead of before, we don't need to // "undo" the effect on overallCachePolicy of a static hint that // gets refined by a dynamic hint. return () => { // If this field returns a composite type or is a root field and // we haven't seen an explicit maxAge hint, set the maxAge to 0 // (uncached) or the default if specified in the constructor. // (Non-object fields by default are assumed to inherit their // cacheability from their parents. But on the other hand, while // root non-object fields can get explicit hints from their // definition on the Query/Mutation object, if that doesn't // exist then there's no parent field that would assign the // default maxAge, so we do it here.) // // You can disable this on a non-root field by writing // `@cacheControl(inheritMaxAge: true)` on it. If you do this, // then its children will be treated like root paths, since // there is no parent maxAge to inherit. // // We do this in the end hook so that dynamic cache control // prevents it from happening (eg, // `info.cacheControl.cacheHint.restrict({maxAge: 60})` should // work rather than doing nothing because we've already set the // max age to the default of 0). This also lets resolvers assume // any hint in `info.cacheControl.cacheHint` was explicitly set. if ( fieldPolicy.maxAge === undefined && ((isCompositeType(targetType) && !inheritMaxAge) || !info.path.prev) ) { fieldPolicy.restrict({ maxAge: defaultMaxAge }); } if (__testing__cacheHints && isRestricted(fieldPolicy)) { const path = responsePathAsArray(info.path).join('.'); if (__testing__cacheHints.has(path)) { throw Error( "shouldn't happen: addHint should only be called once per path", ); } __testing__cacheHints.set(path, { maxAge: fieldPolicy.maxAge, scope: fieldPolicy.scope, }); } requestContext.overallCachePolicy.restrict(fieldPolicy); }; }, }; }, async willSendResponse(requestContext) { const { response, overallCachePolicy, requestIsBatched } = requestContext; const policyIfCacheable = overallCachePolicy.policyIfCacheable(); // If the feature is enabled, there is a non-trivial cache policy, // there are no errors, we actually can write headers, and the request // is not batched (because we have no way of merging the header across // operations in AS3), write the header. if ( calculateHttpHeaders && policyIfCacheable && !response.errors && response.http && !requestIsBatched ) { response.http.headers.set( 'Cache-Control', `max-age=${ policyIfCacheable.maxAge }, ${policyIfCacheable.scope.toLowerCase()}`, ); } }, }; }, }; } function cacheAnnotationFromDirectives( directives: ReadonlyArray<DirectiveNode> | undefined, ): CacheAnnotation | undefined { if (!directives) return undefined; const cacheControlDirective = directives.find( (directive) => directive.name.value === 'cacheControl', ); if (!cacheControlDirective) return undefined; if (!cacheControlDirective.arguments) return undefined; const maxAgeArgument = cacheControlDirective.arguments.find( (argument) => argument.name.value === 'maxAge', ); const scopeArgument = cacheControlDirective.arguments.find( (argument) => argument.name.value === 'scope', ); const inheritMaxAgeArgument = cacheControlDirective.arguments.find( (argument) => argument.name.value === 'inheritMaxAge', ); const scope = scopeArgument?.value?.kind === 'EnumValue' ? (scopeArgument.value.value as CacheScope) : undefined; if ( inheritMaxAgeArgument?.value?.kind === 'BooleanValue' && inheritMaxAgeArgument.value.value ) { // We ignore maxAge if it is also specified. return { inheritMaxAge: true, scope }; } return { maxAge: maxAgeArgument?.value?.kind === 'IntValue' ? parseInt(maxAgeArgument.value.value) : undefined, scope, }; } function cacheAnnotationFromType(t: GraphQLCompositeType): CacheAnnotation { if (t.astNode) { const hint = cacheAnnotationFromDirectives(t.astNode.directives); if (hint) { return hint; } } if (t.extensionASTNodes) { for (const node of t.extensionASTNodes) { const hint = cacheAnnotationFromDirectives(node.directives); if (hint) { return hint; } } } return {}; } function cacheAnnotationFromField( field: GraphQLField<unknown, unknown>, ): CacheAnnotation { if (field.astNode) { const hint = cacheAnnotationFromDirectives(field.astNode.directives); if (hint) { return hint; } } return {}; } function isRestricted(hint: CacheHint) { return hint.maxAge !== undefined || hint.scope !== undefined; } // This plugin does nothing, but it ensures that ApolloServer won't try // to add a default ApolloServerPluginCacheControl. export function ApolloServerPluginCacheControlDisabled(): InternalApolloServerPlugin { return { __internal_plugin_id__() { return 'CacheControl'; }, }; }