diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8b7597743..1f76e89ef66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ The version headers in this history reflect the versions of Apollo Server itself - `apollo-server-lambda`: The handler returned by `createHandler` can now only be called as an async function returning a `Promise` (it no longer optionally accepts a callback as the third argument). All current Lambda Node runtimes support this invocation mode (so `exports.handler = server.createHandler()` will keep working without any changes), but if you've written your own handler which calls the handler returned by `createHandler` with a callback, you'll need to handle its `Promise` return value instead. - `apollo-server-lambda`: This package is now implemented as a wrapper around `apollo-server-express`. `createHandler`'s argument now has different options: `expressGetMiddlewareOptions` which includes things like `cors` that is passed through to `apollo-server-express`'s `getMiddleware`, and `expressAppFromMiddleware` which lets you customize HTTP processing. The `context` function now receives an `express: { req, res }` option in addition to `event` and `context`. - The `tracing` option to `new ApolloServer` has been removed, and the `apollo-server-tracing` package has been deprecated and is no longer being published. This package implemented an inefficient JSON format for execution traces returned on the `tracing` GraphQL response extension; it was only consumed by the deprecated `engineproxy` and Playground. If you really need this format, the old version of `apollo-server-tracing` should still work (`new ApolloServer({plugins: [require('apollo-server-tracing').plugin()]})`). -- The `cacheControl` option to `new ApolloServer` has been removed. The functionality provided by `cacheControl: true` or `cacheControl: {stripFormattedExtensions: false}` (which included a `cacheControl` extension in the GraphQL response, for use by the deprecated `engineproxy`) has been entirely removed. By default, Apollo Server continues to calculate an overall cache policy and to set the `Cache-Control` HTTP header, but this is now implemented directly inside `apollo-server-core` rather than a separate `apollo-cache-control` package (this package has been deprecated and is no longer being published). Tweaking cache control settings like `defaultMaxAge` is now done via the newly exported `ApolloServerPluginCacheControl` plugin rather than as a top-level constructor option. This follows the same pattern as the other built-in plugins like usage reporting. The `CacheHint` and `CacheScope` types are now exported from `apollo-server-types`. +- The `cacheControl` option to `new ApolloServer` has been removed. The functionality provided by `cacheControl: true` or `cacheControl: {stripFormattedExtensions: false}` (which included a `cacheControl` extension in the GraphQL response, for use by the deprecated `engineproxy`) has been entirely removed. By default, Apollo Server continues to calculate an overall cache policy and to set the `Cache-Control` HTTP header, but this is now implemented directly inside `apollo-server-core` rather than a separate `apollo-cache-control` package (this package has been deprecated and is no longer being published). Tweaking cache control settings like `defaultMaxAge` is now done via the newly exported `ApolloServerPluginCacheControl` plugin rather than as a top-level constructor option. This follows the same pattern as the other built-in plugins like usage reporting. The `CacheHint` and `CacheScope` types are now exported from `apollo-server-types`. The `info.cacheControl.cacheHint` object now has additional methods `replace`, `restrict`, and `policyIfCacheable`, and its fields update when those methods or `setCacheHint` are called. These methods also exist on `requestContext.overallCachePolicy`, which is always defined and which should not be overwritten (use `replace` instead). There is also a new function `info.cacheControl.cacheHintFromType` available. `@cacheControl` directives on type extensions are no longer ignored. - When using a non-serverless framework integration (Express, Fastify, Hapi, Koa, Micro, or Cloudflare), you now *must* `await server.start()` before attaching the server to your framework. (This method was introduced in v2.22 but was optional before Apollo Server 3.) This does not apply to the batteries-included `apollo-server` or to serverless framework integrations. - Top-level exports have changed. E.g., diff --git a/docs/source/performance/caching.md b/docs/source/performance/caching.md index 0e3a2befa73..43ea2c0970e 100644 --- a/docs/source/performance/caching.md +++ b/docs/source/performance/caching.md @@ -135,7 +135,7 @@ const resolvers = { The `setCacheHint` method accepts an object with the same fields as [the `@cacheControl` directive](#in-your-schema-static). -The `cacheControl` object also has a `cacheHint` field which returns the hint set in the schema, if any. (Calling `info.cacheControl.setCacheHint` does not update `info.cacheControl.cacheHint`.) +The `cacheControl` object also has a `cacheHint` field which returns the field's current hint. This object also has a few other helpful methods, such as `info.cacheControl.cacheHint.restrict({ maxAge, scope })` which is similar to `setCacheHint` but it will never make `makeAge` larger or change `scope` from `PRIVATE` to `PUBLIC`. There is also a function `info.cacheControl.cacheHintFromType()` which takes an object type from a GraphQL AST and returns a cache hint which can be passed to `setCacheHint` or `restrict`; it may be useful for implementing resolvers that return unions or interfaces. ### Default `maxAge` diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index ff359878a4b..3f36486f8b3 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -55,6 +55,7 @@ import { ApolloServerPluginFrontendGraphQLPlayground, } from './plugin'; import { InternalPluginId, pluginIsInternal } from './internalPlugin'; +import { newCachePolicy } from './cachePolicy'; const NoIntrospection = (context: ValidationContext) => ({ Field(node: FieldDefinitionNode) { @@ -912,6 +913,7 @@ export class ApolloServerBase { }, }, debug: options.debug, + overallCachePolicy: newCachePolicy(), }; return processGraphQLRequest(options, requestCtx); diff --git a/packages/apollo-server-core/src/__tests__/cachePolicy.test.ts b/packages/apollo-server-core/src/__tests__/cachePolicy.test.ts new file mode 100644 index 00000000000..34e40e9f4d3 --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/cachePolicy.test.ts @@ -0,0 +1,109 @@ +import { CachePolicy, CacheScope } from 'apollo-server-types'; +import { newCachePolicy } from '../cachePolicy'; + +describe('newCachePolicy', () => { + let cachePolicy: CachePolicy; + beforeEach(() => { + cachePolicy = newCachePolicy(); + }); + + it('starts uncacheable', () => { + expect(cachePolicy.maxAge).toBeUndefined(); + expect(cachePolicy.scope).toBeUndefined(); + }); + + it('restricting maxAge positive makes restricted', () => { + cachePolicy.restrict({ maxAge: 10 }); + }); + + it('restricting maxAge 0 makes restricted', () => { + cachePolicy.restrict({ maxAge: 0 }); + }); + + it('restricting scope to private makes restricted', () => { + cachePolicy.restrict({ scope: CacheScope.Private }); + }); + + it('returns lowest max age value', () => { + cachePolicy.restrict({ maxAge: 10 }); + cachePolicy.restrict({ maxAge: 20 }); + + expect(cachePolicy.maxAge).toBe(10); + }); + + it('returns lowest max age value in other order', () => { + cachePolicy.restrict({ maxAge: 20 }); + cachePolicy.restrict({ maxAge: 10 }); + + expect(cachePolicy.maxAge).toBe(10); + }); + + it('maxAge 0 if any cache hint has a maxAge of 0', () => { + cachePolicy.restrict({ maxAge: 120 }); + cachePolicy.restrict({ maxAge: 0 }); + cachePolicy.restrict({ maxAge: 20 }); + + expect(cachePolicy.maxAge).toBe(0); + }); + + it('returns undefined if first cache hint has a maxAge of 0', () => { + cachePolicy.restrict({ maxAge: 0 }); + cachePolicy.restrict({ maxAge: 20 }); + + expect(cachePolicy.maxAge).toBe(0); + }); + + it('only restricting maxAge keeps scope undefined', () => { + cachePolicy.restrict({ maxAge: 10 }); + + expect(cachePolicy.scope).toBeUndefined(); + }); + + it('returns PRIVATE scope if any cache hint has PRIVATE scope', () => { + cachePolicy.restrict({ + maxAge: 10, + scope: CacheScope.Public, + }); + cachePolicy.restrict({ + maxAge: 10, + scope: CacheScope.Private, + }); + + expect(cachePolicy).toHaveProperty('scope', CacheScope.Private); + }); + + it('policyIfCacheable', () => { + expect(cachePolicy.policyIfCacheable()).toBeNull(); + + cachePolicy.restrict({ scope: CacheScope.Private }); + expect(cachePolicy.scope).toBe(CacheScope.Private); + expect(cachePolicy.policyIfCacheable()).toBeNull(); + + cachePolicy.restrict({ maxAge: 10 }); + expect(cachePolicy).toMatchObject({ + maxAge: 10, + scope: CacheScope.Private, + }); + expect(cachePolicy.policyIfCacheable()).toStrictEqual({ + maxAge: 10, + scope: CacheScope.Private, + }); + + cachePolicy.restrict({ maxAge: 0 }); + expect(cachePolicy).toMatchObject({ + maxAge: 0, + scope: CacheScope.Private, + }); + expect(cachePolicy.policyIfCacheable()).toBeNull(); + }); + + it('replace', () => { + cachePolicy.restrict({ maxAge: 10, scope: CacheScope.Private }); + cachePolicy.replace({ maxAge: 20, scope: CacheScope.Public }); + + expect(cachePolicy).toMatchObject({ + maxAge: 20, + scope: CacheScope.Public, + }); + }); +}); diff --git a/packages/apollo-server-core/src/__tests__/runQuery.test.ts b/packages/apollo-server-core/src/__tests__/runQuery.test.ts index d928a82f21e..36c28e57ffc 100644 --- a/packages/apollo-server-core/src/__tests__/runQuery.test.ts +++ b/packages/apollo-server-core/src/__tests__/runQuery.test.ts @@ -29,6 +29,7 @@ import { } from 'apollo-server-plugin-base'; import { InMemoryLRUCache } from 'apollo-server-caching'; import { generateSchemaHash } from "../utils/schemaHash"; +import { newCachePolicy } from '../cachePolicy'; // This is a temporary kludge to ensure we preserve runQuery behavior with the // GraphQLRequestProcessor refactoring. @@ -57,6 +58,7 @@ function runQuery( context: options.context || {}, debug: options.debug, cache: {} as any, + overallCachePolicy: newCachePolicy(), ...requestContextExtra, }); } diff --git a/packages/apollo-server-core/src/cachePolicy.ts b/packages/apollo-server-core/src/cachePolicy.ts new file mode 100644 index 00000000000..d565969b768 --- /dev/null +++ b/packages/apollo-server-core/src/cachePolicy.ts @@ -0,0 +1,33 @@ +import { CacheHint, CachePolicy, CacheScope } from 'apollo-server-types'; + +export function newCachePolicy(): CachePolicy { + return { + maxAge: undefined, + scope: undefined, + restrict(hint: CacheHint) { + if ( + hint.maxAge !== undefined && + (this.maxAge === undefined || hint.maxAge < this.maxAge) + ) { + this.maxAge = hint.maxAge; + } + if (hint.scope !== undefined && this.scope !== CacheScope.Private) { + this.scope = hint.scope; + } + }, + replace(hint: CacheHint) { + if (hint.maxAge !== undefined) { + this.maxAge = hint.maxAge; + } + if (hint.scope !== undefined) { + this.scope = hint.scope; + } + }, + policyIfCacheable() { + if (this.maxAge === undefined || this.maxAge === 0) { + return null; + } + return { maxAge: this.maxAge, scope: this.scope ?? CacheScope.Public }; + }, + }; +} diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts index 9d6a2cf55c3..57cf991d7de 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts @@ -130,6 +130,35 @@ describe('@cacheControl directives', () => { expect(hints).toStrictEqual(new Map([['droid', { maxAge: 60 }]])); }); + it('should set the specified maxAge for a field from a cache hint on the target type extension', async () => { + const schema = buildSchemaWithCacheControlSupport(` + type Query { + droid(id: ID!): Droid + } + + type Droid { + id: ID! + name: String! + } + + extend type Droid @cacheControl(maxAge: 60) + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toStrictEqual(new Map([['droid', { maxAge: 60 }]])); + }); + it('should overwrite the default maxAge when maxAge=0 is specified on the type', async () => { const schema = buildSchemaWithCacheControlSupport(` type Query { diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlPlugin.test.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlPlugin.test.ts index 9b1985aa566..213c30d4ae4 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlPlugin.test.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlPlugin.test.ts @@ -3,12 +3,10 @@ import { Headers } from 'apollo-server-env'; import { CacheHint, CacheScope, - GraphQLRequestContext, } from 'apollo-server-types'; import { ApolloServerPluginCacheControl, ApolloServerPluginCacheControlOptions, - PolicyUpdater, } from '../'; import { GraphQLRequestContextWillSendResponse, @@ -114,64 +112,4 @@ describe('plugin', () => { }); }); }); - - describe('PolicyUpdater', () => { - let hints: PolicyUpdater; - let requestContext: Pick; - beforeEach(() => { - requestContext = {}; - hints = new PolicyUpdater(requestContext); - }); - - it('returns undefined without cache hints', () => { - expect(requestContext.overallCachePolicy).toBeUndefined(); - }); - - it('returns lowest max age value', () => { - hints.addHint({ maxAge: 10 }); - hints.addHint({ maxAge: 20 }); - - expect(requestContext.overallCachePolicy).toHaveProperty('maxAge', 10); - }); - - it('returns undefined if any cache hint has a maxAge of 0', () => { - hints.addHint({ maxAge: 120 }); - hints.addHint({ maxAge: 0 }); - hints.addHint({ maxAge: 20 }); - - expect(requestContext.overallCachePolicy).toBeUndefined(); - }); - - it('returns undefined if first cache hint has a maxAge of 0', () => { - hints.addHint({ maxAge: 0 }); - hints.addHint({ maxAge: 20 }); - - expect(requestContext.overallCachePolicy).toBeUndefined(); - }); - - it('returns PUBLIC scope by default', () => { - hints.addHint({ maxAge: 10 }); - - expect(requestContext.overallCachePolicy).toHaveProperty( - 'scope', - CacheScope.Public, - ); - }); - - it('returns PRIVATE scope if any cache hint has PRIVATE scope', () => { - hints.addHint({ - maxAge: 10, - scope: CacheScope.Public, - }); - hints.addHint({ - maxAge: 10, - scope: CacheScope.Private, - }); - - expect(requestContext.overallCachePolicy).toHaveProperty( - 'scope', - CacheScope.Private, - ); - }); - }); }); diff --git a/packages/apollo-server-core/src/plugin/cacheControl/index.ts b/packages/apollo-server-core/src/plugin/cacheControl/index.ts index c2bcc0a1b33..9dc26373be5 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/index.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/index.ts @@ -1,12 +1,15 @@ -import type { CacheHint, GraphQLRequestContext } from 'apollo-server-types'; +import type { CacheHint, CachePolicy } from 'apollo-server-types'; import { CacheScope } from 'apollo-server-types'; import { DirectiveNode, getNamedType, + GraphQLCompositeType, + GraphQLField, GraphQLInterfaceType, GraphQLObjectType, responsePathAsArray, } from 'graphql'; +import { newCachePolicy } from '../../cachePolicy'; import type { InternalApolloServerPlugin } from '../../internalPlugin'; export interface ApolloServerPluginCacheControlOptions { /** @@ -30,72 +33,46 @@ export interface ApolloServerPluginCacheControlOptions { declare module 'graphql/type/definition' { interface GraphQLResolveInfo { cacheControl: { - setCacheHint: (hint: CacheHint) => void; - cacheHint: CacheHint; + cacheHint: CachePolicy; + // Shorthand for `cacheHint.replace(hint)`; also for compatibility with + // the Apollo Server 2.x API. + setCacheHint(hint: CacheHint): void; + cacheHintFromType(t: GraphQLCompositeType): CacheHint | undefined; }; } } -// Exported for tests only. -export class PolicyUpdater { - private overallCachePolicyIsUncacheable = false; - constructor( - private requestContext: Pick, - ) {} - addHint(hint: CacheHint) { - // If we've already seen that some piece of the response has maxAge 0, - // then there's nothing we can learn that can change the policy from - // undefined. - if (this.overallCachePolicyIsUncacheable) { - return; - } - - // If this piece is entirely uncacheable, then the overall policy is - // undefined (uncacheable) and no information we learn later can change - // our mind. (This is distinct from "the policy we've learned so far is - // 'undefined' but that's just because we haven't seen any hints yet".) - if (hint.maxAge === 0) { - this.requestContext.overallCachePolicy = undefined; - this.overallCachePolicyIsUncacheable = true; - return; - } +export function ApolloServerPluginCacheControl( + options: ApolloServerPluginCacheControlOptions = Object.create(null), +): InternalApolloServerPlugin { + const typeCacheHintCache = new Map(); + const fieldCacheHintCache = new Map< + GraphQLField, + CacheHint + >(); - if (!this.requestContext.overallCachePolicy) { - if (hint.maxAge === undefined) { - // This shouldn't happen. If we've gotten this far, then the reason - // requestContext.overallCachePolicy is unset is because we haven't seen - // any hints yet, not because some hint told us that the operation is - // uncacheable (overallCachePolicyIsUncacheable would have been true - // otherwise). Every time we start to resolve a field, this function - // gets called. So this must be the first field we're resolving, which - // means it must be a root field. But root field maxAge is always a - // number. So this shouldn't happen. - throw Error("Shouldn't happen: first hint has undefined maxAge?"); - } - this.requestContext.overallCachePolicy = { - maxAge: hint.maxAge, - scope: hint.scope ?? CacheScope.Public, - }; - return; + function memoizedCacheHintFromType(t: GraphQLCompositeType): CacheHint { + const cachedHint = typeCacheHintCache.get(t); + if (cachedHint) { + return cachedHint; } + const hint = cacheHintFromType(t); + typeCacheHintCache.set(t, hint); + return hint; + } - // OK! We already have a cache policy, and we have a new hint. Let's - // combine! Take the minimum maxAge and the privatest scope. - if ( - hint.maxAge !== undefined && - hint.maxAge < this.requestContext.overallCachePolicy.maxAge - ) { - this.requestContext.overallCachePolicy.maxAge = hint.maxAge; - } - if (hint.scope === CacheScope.Private) { - this.requestContext.overallCachePolicy.scope = CacheScope.Private; + function memoizedCacheHintFromField( + field: GraphQLField, + ): CacheHint { + const cachedHint = fieldCacheHintCache.get(field); + if (cachedHint) { + return cachedHint; } + const hint = cacheHintFromField(field); + fieldCacheHintCache.set(field, hint); + return hint; } -} -export function ApolloServerPluginCacheControl( - options: ApolloServerPluginCacheControlOptions = Object.create(null), -): InternalApolloServerPlugin { return { __internal_plugin_id__() { return 'CacheControl'; @@ -108,27 +85,35 @@ export function ApolloServerPluginCacheControl( return { executionDidStart: () => { - let policyUpdater: PolicyUpdater | undefined; - // Did something set the overall cache policy before we've even // started? If so, consider that as an override and don't touch it. - // Otherwise, create a PolicyUpdater which tracks a tiny bit of state - // and updates requestContext.overallCachePolicy when necessary. + // Just put set up fake `info.cacheControl` objects and otherwise + // don't track cache policy. // - // XXX I'm not really sure when requestContext.overallCachePolicy - // could be already set. The main use case for setting - // overallCachePolicy outside of this plugin is - // apollo-server-plugin-response-cache, but when it sets the policy we - // never get to execution at all! This is preserving behavior - // introduced in #3997 but I'm not sure it was ever actually - // necessary. - if (!requestContext.overallCachePolicy) { - policyUpdater = new PolicyUpdater(requestContext); + // (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, + }; + }, + }; } return { willResolveField({ info }) { - let hint: CacheHint = {}; + const fieldPolicy = newCachePolicy(); // If this field's resolver returns an object or interface, look for // hints on that return type. @@ -137,23 +122,16 @@ export function ApolloServerPluginCacheControl( targetType instanceof GraphQLObjectType || targetType instanceof GraphQLInterfaceType ) { - if (targetType.astNode) { - hint = mergeHints( - hint, - cacheHintFromDirectives(targetType.astNode.directives), - ); - } + fieldPolicy.replace(memoizedCacheHintFromType(targetType)); } // Look for hints on the field itself (on its parent type), taking // precedence over previously calculated hints. - const fieldDef = info.parentType.getFields()[info.fieldName]; - if (fieldDef.astNode) { - hint = mergeHints( - hint, - cacheHintFromDirectives(fieldDef.astNode.directives), - ); - } + fieldPolicy.replace( + memoizedCacheHintFromField( + info.parentType.getFields()[info.fieldName], + ), + ); // If this resolver returns an object or is a root field and we haven't // seen an explicit maxAge hint, set the maxAge to 0 (uncached) or the @@ -167,34 +145,36 @@ export function ApolloServerPluginCacheControl( (targetType instanceof GraphQLObjectType || targetType instanceof GraphQLInterfaceType || !info.path.prev) && - hint.maxAge === undefined + fieldPolicy.maxAge === undefined ) { - hint.maxAge = defaultMaxAge; + fieldPolicy.restrict({ maxAge: defaultMaxAge }); } info.cacheControl = { setCacheHint: (dynamicHint: CacheHint) => { - hint = mergeHints(hint, dynamicHint); + fieldPolicy.replace(dynamicHint); }, - cacheHint: hint, + cacheHint: fieldPolicy, + cacheHintFromType, }; // When the field is done, call addHint once. By calling addHint // once, we don't need to "undo" the effect on overallCachePolicy // of a static hint that gets refined by a dynamic hint. return () => { - if (hint.maxAge !== undefined || hint.scope !== undefined) { - if (__testing__cacheHints) { - 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, hint); + 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", + ); } - policyUpdater?.addHint(hint); + __testing__cacheHints.set(path, { + maxAge: fieldPolicy.maxAge, + scope: fieldPolicy.scope, + }); } + requestContext.overallCachePolicy.restrict(fieldPolicy); }; }, }; @@ -203,20 +183,22 @@ export function ApolloServerPluginCacheControl( willSendResponse(requestContext) { const { response, overallCachePolicy } = requestContext; + const policyIfCacheable = overallCachePolicy.policyIfCacheable(); + // If the feature is enabled, there is a non-trivial cache policy, // there are no errors, and we actually can write headers, write the // header. if ( calculateHttpHeaders && - overallCachePolicy && + policyIfCacheable && !response.errors && response.http ) { response.http.headers.set( 'Cache-Control', `max-age=${ - overallCachePolicy.maxAge - }, ${overallCachePolicy.scope.toLowerCase()}`, + policyIfCacheable.maxAge + }, ${policyIfCacheable.scope.toLowerCase()}`, ); } }, @@ -261,16 +243,36 @@ function cacheHintFromDirectives( }; } -function mergeHints( - hint: CacheHint, - otherHint: CacheHint | undefined, -): CacheHint { - if (!otherHint) return hint; +function cacheHintFromType(t: GraphQLCompositeType): CacheHint { + if (t.astNode) { + const hint = cacheHintFromDirectives(t.astNode.directives); + if (hint) { + return hint; + } + } + if (t.extensionASTNodes) { + for (const node of t.extensionASTNodes) { + const hint = cacheHintFromDirectives(node.directives); + if (hint) { + return hint; + } + } + } + return {}; +} - return { - maxAge: otherHint.maxAge !== undefined ? otherHint.maxAge : hint.maxAge, - scope: otherHint.scope || hint.scope, - }; +function cacheHintFromField(field: GraphQLField): CacheHint { + if (field.astNode) { + const hint = cacheHintFromDirectives(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 diff --git a/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts b/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts index bf789d0aae1..fc736804fe9 100644 --- a/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts +++ b/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts @@ -177,10 +177,9 @@ export function ApolloServerPluginUsageReporting( async function sendAllReportsAndReportErrors(): Promise { await Promise.all( - Object.keys( - reportDataByExecutableSchemaId, - ).map((executableSchemaId) => - sendReportAndReportErrors(executableSchemaId), + Object.keys(reportDataByExecutableSchemaId).map( + (executableSchemaId) => + sendReportAndReportErrors(executableSchemaId), ), ); } @@ -458,17 +457,18 @@ export function ApolloServerPluginUsageReporting( treeBuilder.trace.forbiddenOperation = !!metrics.forbiddenOperation; treeBuilder.trace.registeredOperation = !!metrics.registeredOperation; - if (requestContext.overallCachePolicy) { + const policyIfCacheable = + requestContext.overallCachePolicy.policyIfCacheable(); + if (policyIfCacheable) { treeBuilder.trace.cachePolicy = new Trace.CachePolicy({ scope: - requestContext.overallCachePolicy.scope === CacheScope.Private + policyIfCacheable.scope === CacheScope.Private ? Trace.CachePolicy.Scope.PRIVATE - : requestContext.overallCachePolicy.scope === - CacheScope.Public + : policyIfCacheable.scope === CacheScope.Public ? Trace.CachePolicy.Scope.PUBLIC : Trace.CachePolicy.Scope.UNKNOWN, // Convert from seconds to ns. - maxAgeNs: requestContext.overallCachePolicy.maxAge * 1e9, + maxAgeNs: policyIfCacheable.maxAge * 1e9, }); } @@ -635,11 +635,8 @@ export function ApolloServerPluginUsageReporting( if (clientInfo) { // While there is a clientAddress protobuf field, the backend // doesn't pay attention to it yet, so we'll ignore it for now. - const { - clientName, - clientVersion, - clientReferenceId, - } = clientInfo; + const { clientName, clientVersion, clientReferenceId } = + clientInfo; // the backend makes the choice of mapping clientName => clientReferenceId if // no custom reference id is provided treeBuilder.trace.clientVersion = clientVersion || ''; @@ -700,9 +697,7 @@ export function ApolloServerPluginUsageReporting( // See comment above for why `didEnd` must be called in two hooks. // The type assertion is valid becaus we check didResolveSource above. didEnd( - requestContext as GraphQLRequestContextDidEncounterErrors< - TContext - > & + requestContext as GraphQLRequestContextDidEncounterErrors & GraphQLRequestContextDidResolveSource, ); }, diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index f3b467d9a66..816ef58ea8c 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -19,6 +19,7 @@ import { } from './requestPipeline'; import { ApolloServerPlugin } from 'apollo-server-plugin-base'; import { WithRequired, GraphQLExecutionResult } from 'apollo-server-types'; +import { newCachePolicy } from './cachePolicy'; export interface HttpQueryRequest { method: string; @@ -270,6 +271,7 @@ export async function processHTTPRequest( cache: options.cache, debug: options.debug, metrics: {}, + overallCachePolicy: newCachePolicy(), }; } diff --git a/packages/apollo-server-core/src/utils/pluginTestHarness.ts b/packages/apollo-server-core/src/utils/pluginTestHarness.ts index e361ee2ab2a..6b485ffee57 100644 --- a/packages/apollo-server-core/src/utils/pluginTestHarness.ts +++ b/packages/apollo-server-core/src/utils/pluginTestHarness.ts @@ -27,6 +27,7 @@ import { InMemoryLRUCache } from 'apollo-server-caching'; import { Dispatcher } from './dispatcher'; import { generateSchemaHash } from './schemaHash'; import { getOperationAST, parse, validate as graphqlValidate } from 'graphql'; +import { newCachePolicy } from '../cachePolicy'; // This test harness guarantees the presence of `query`. type IPluginTestHarnessGraphqlRequest = WithRequired; @@ -132,13 +133,16 @@ export default async function pluginTestHarness({ source: graphqlRequest.query, cache: new InMemoryLRUCache(), context, + overallCachePolicy: newCachePolicy(), }; if (requestContext.source === undefined) { throw new Error("No source provided for test"); } - requestContext.overallCachePolicy = overallCachePolicy; + if (overallCachePolicy) { + requestContext.overallCachePolicy.replace(overallCachePolicy); + } if (typeof pluginInstance.requestDidStart !== "function") { throw new Error("This test harness expects this to be defined."); diff --git a/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts b/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts index dc044ee0a1c..9e57c3b7dd2 100644 --- a/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts +++ b/packages/apollo-server-plugin-response-cache/src/ApolloServerPluginResponseCache.ts @@ -172,7 +172,7 @@ export default function plugin( const value: CacheValue = JSON.parse(serializedValue); // Use cache policy from the cache (eg, to calculate HTTP response // headers). - requestContext.overallCachePolicy = value.cachePolicy; + requestContext.overallCachePolicy.replace(value.cachePolicy); requestContext.metrics.responseCacheHit = true; age = Math.round((+new Date() - value.cacheTime) / 1000); return { data: value.data }; @@ -241,13 +241,11 @@ export default function plugin( if (!shouldWriteToCache) return; } - const { response, overallCachePolicy } = requestContext; - if ( - response.errors || - !response.data || - !overallCachePolicy || - overallCachePolicy.maxAge <= 0 - ) { + const { response } = requestContext; + const { data } = response; + const policyIfCacheable = + requestContext.overallCachePolicy.policyIfCacheable(); + if (response.errors || !data || !policyIfCacheable) { // This plugin never caches errors or anything without a cache policy. // // There are two reasons we don't cache errors. The user-level @@ -262,8 +260,6 @@ export default function plugin( return; } - const data = response.data!; - // We're pretty sure that any path that calls willSendResponse with a // non-error response will have already called our execute hook above, // but let's just double-check that, since accidentally ignoring @@ -274,16 +270,16 @@ export default function plugin( ); } - function cacheSetInBackground( + const cacheSetInBackground = ( contextualCacheKeyFields: ContextualCacheKey, - ) { + ): void => { const key = cacheKeyString({ ...baseCacheKey!, ...contextualCacheKeyFields, }); const value: CacheValue = { data, - cachePolicy: overallCachePolicy!, + cachePolicy: policyIfCacheable, cacheTime: +new Date(), }; const serializedValue = JSON.stringify(value); @@ -295,11 +291,11 @@ export default function plugin( // still calls `cache.set` synchronously (ie, that it writes to // InMemoryLRUCache synchronously). cache - .set(key, serializedValue, { ttl: overallCachePolicy!.maxAge }) + .set(key, serializedValue, { ttl: policyIfCacheable.maxAge }) .catch(logger.warn); - } + }; - const isPrivate = overallCachePolicy.scope === CacheScope.Private; + const isPrivate = policyIfCacheable.scope === CacheScope.Private; if (isPrivate) { if (!options.sessionId) { logger.warn( diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index e66da2f2520..9f90cd3d02f 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -78,7 +78,7 @@ export interface ApolloConfig { graphRef?: string; } - export interface GraphQLServiceContext { +export interface GraphQLServiceContext { logger: Logger; schema: GraphQLSchema; schemaHash: SchemaHash; @@ -153,8 +153,7 @@ export interface GraphQLRequestContext> { debug?: boolean; - // Not readonly: plugins can set it. - overallCachePolicy?: Required | undefined; + overallCachePolicy: CachePolicy; } export type ValidationRule = (context: ValidationContext) => ASTVisitor; @@ -246,6 +245,10 @@ export type GraphQLRequestContextWillSendResponse = | 'response' >; +/** + * CacheHint represents a contribution to an overall cache policy. It can + * specify a maxAge and/or a scope. + */ export interface CacheHint { maxAge?: number; scope?: CacheScope; @@ -255,3 +258,26 @@ export enum CacheScope { Public = 'PUBLIC', Private = 'PRIVATE', } + +/** + * CachePolicy is a mutable CacheHint with helpful methods for updating its + * fields. + */ +export interface CachePolicy extends CacheHint { + /** + * Mutate this CachePolicy by replacing each field defined in `hint`. This can + * make the policy more restrictive or less restrictive. + */ + replace(hint: CacheHint): void; + /** + * Mutate this CachePolicy by restricting each field defined in `hint`. This + * can only make the policy more restrictive: a previously defined `maxAge` + * can only be reduced, and a previously Private scope cannot be made Public. + */ + restrict(hint: CacheHint): void; + /** + * If this policy has a positive `maxAge`, then return a copy of itself as a + * `CacheHint` with both fields defined. Otherwise return null. + */ + policyIfCacheable(): Required | null; +}