UNPKG

@apollo/server

Version:
454 lines (413 loc) 17.9 kB
import type { ApolloServerPlugin } from '../../externalTypes/index.js'; import { type DirectiveNode, getNamedType, type GraphQLCompositeType, type GraphQLField, isCompositeType, isInterfaceType, isObjectType, responsePathAsArray, } from 'graphql'; import { newCachePolicy } from '../../cachePolicy.js'; import { internalPlugin } from '../../internalPlugin.js'; import { LRUCache } from 'lru-cache'; import type { CacheHint, CacheScope, GraphQLResolveInfoWithCacheControl, } from '@apollo/cache-control-types'; /** * CacheAnnotation represents the contents of a `@cacheControl` directive. * (`inheritMaxAge` is part of this interface and not CacheHint, because * `inheritMaxAge` isn't a contributing piece of a cache policy: it just means * to not apply default values in some contexts.) */ interface CacheAnnotation extends CacheHint { inheritMaxAge?: true; } 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. If true (the * default), the header is written on all responses (with a value of * `no-store` for non-cacheable responses). If `'if-cacheable'`, the header is * only written for cacheable responses. If false, the header is never * written. A response is cacheable if its overall cache policy has a non-zero * `maxAge`, and the body is a single result rather than an incremental * delivery response, and the body contains no errors. */ calculateHttpHeaders?: boolean | 'if-cacheable'; // For testing only. __testing__cacheHints?: Map<string, CacheHint>; } export function ApolloServerPluginCacheControl( options: ApolloServerPluginCacheControlOptions = Object.create(null), ): ApolloServerPlugin { let typeAnnotationCache: LRUCache<GraphQLCompositeType, CacheAnnotation>; let fieldAnnotationCache: LRUCache< GraphQLField<unknown, unknown>, CacheAnnotation >; return internalPlugin({ __internal_plugin_id__: 'CacheControl', __is_disabled_plugin__: false, 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 = new LRUCache<GraphQLCompositeType, CacheAnnotation>( { max: Object.values(schema.getTypeMap()).filter(isCompositeType) .length, }, ); fieldAnnotationCache = new LRUCache< GraphQLField<unknown, unknown>, CacheAnnotation >({ 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) { 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; } 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 }) { // This `as` is "safe" in the sense that this is the statement // that makes a GraphQLResolveInfo into a // GraphQLResolveInfoWithCacheControl. (info as GraphQLResolveInfoWithCacheControl).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); } // This `as` is "safe" in the sense that this is the statement // that makes a GraphQLResolveInfo into a // GraphQLResolveInfoWithCacheControl. (info as GraphQLResolveInfoWithCacheControl).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) { // This hook is just for setting response headers, so make sure that // hasn't been disabled. if (!calculateHttpHeaders) { return; } const { response, overallCachePolicy } = requestContext; // Look to see if something has already set the cache-control header. // This could be a different plugin... or it could be this very plugin // operating on a different operation in the same batched HTTP // request. const existingCacheControlHeader = parseExistingCacheControlHeader( response.http.headers.get('cache-control'), ); // If the header contains something other than a value that this // plugin sets, then we leave it alone. We don't want to mangle // something important that you set! That said, it's probably best to // have only one piece of code that writes to a given header, so you // should probably set `calculateHttpHeaders: false` on this plugin. if (existingCacheControlHeader.kind === 'unparsable') { return; } const cachePolicy = newCachePolicy(); cachePolicy.replace(overallCachePolicy); if (existingCacheControlHeader.kind === 'parsable-and-cacheable') { cachePolicy.restrict(existingCacheControlHeader.hint); } const policyIfCacheable = cachePolicy.policyIfCacheable(); if ( // This code path is only for if we believe it is cacheable. policyIfCacheable && // Either there wasn't a cache-control header already, or we've // already incorporated it into policyIfCacheable. (If we couldn't // parse it, that means some other plugin or mechanism set the // header. This is confusing, so we just don't make any more // changes. You should probably set `calculateHttpHeaders` to false // in that case and only set the header from one place.) existingCacheControlHeader.kind !== 'uncacheable' && // At least for now, we don't set cache-control headers for // incremental delivery responses, since we don't know if a later // part of the execution will affect the cache policy (perhaps // dynamically). (Note that willSendResponse is called when the // initial payload is sent, not the final payload.) response.body.kind === 'single' && !response.body.singleResult.errors ) { response.http.headers.set( 'cache-control', `max-age=${ policyIfCacheable.maxAge }, ${policyIfCacheable.scope.toLowerCase()}`, ); } else if (calculateHttpHeaders !== 'if-cacheable') { // The response is not cacheable, so make sure it doesn't get // cached. This is especially important for GET requests, because // browsers and other agents cache many GET requests by default. // (But if some other plugin set the header to a value that this // plugin does not produce, we don't do anything.) response.http.headers.set( 'cache-control', CACHE_CONTROL_HEADER_UNCACHEABLE, ); } }, }; }, }); } const CACHE_CONTROL_HEADER_CACHEABLE_REGEXP = /^max-age=(\d+), (public|private)$/; const CACHE_CONTROL_HEADER_UNCACHEABLE = 'no-store'; type ExistingCacheControlHeader = | { kind: 'no-header' } | { kind: 'uncacheable' } | { kind: 'parsable-and-cacheable'; hint: CacheHint } | { kind: 'unparsable' }; function parseExistingCacheControlHeader( header: string | undefined, ): ExistingCacheControlHeader { if (!header) { return { kind: 'no-header' }; } if (header === CACHE_CONTROL_HEADER_UNCACHEABLE) { return { kind: 'uncacheable' }; } const match = CACHE_CONTROL_HEADER_CACHEABLE_REGEXP.exec(header); if (!match) { return { kind: 'unparsable' }; } return { kind: 'parsable-and-cacheable', hint: { maxAge: +match[1], scope: match[2] === 'public' ? 'PUBLIC' : 'PRIVATE', }, }; } 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 scopeString = scopeArgument?.value?.kind === 'EnumValue' ? scopeArgument.value.value : undefined; const scope: CacheScope | undefined = scopeString === 'PUBLIC' || scopeString === 'PRIVATE' ? scopeString : 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; }