diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f76e89ef66..1a4c409df62 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 `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. +- 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. Fields returning union types are now treated similarly to fields returning object and interface types (`@cacheControl` directives on the type are honored, the default `maxAge` is applied to them). New feature: `@cacheControl(inheritMaxAge: true)` when applied to a composite type or a field returning a composite type means that the default `maxAge` is not applied to that field (unless it is a root field). - 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 1100d5e10b3..352b2dda44f 100644 --- a/docs/source/performance/caching.md +++ b/docs/source/performance/caching.md @@ -46,7 +46,8 @@ enum CacheControlScope { directive @cacheControl( maxAge: Int scope: CacheControlScope -) on FIELD_DEFINITION | OBJECT | INTERFACE + inheritMaxAge: Boolean +) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION ``` The `@cacheControl` directive accepts the following arguments: @@ -55,6 +56,7 @@ The `@cacheControl` directive accepts the following arguments: |------|-------------| | `maxAge` | The maximum amount of time the field's cached value is valid, in seconds. The default value is `0`, but you can [set a different default](#setting-the-default-maxage). | | `scope` | If `PRIVATE`, the field's value is specific to a single user. The default value is `PUBLIC`. See also [Identifying users for `PRIVATE` responses](#identifying-users-for-private-responses). | +| `inheritMaxAge` | If `true`, inherits the `maxAge` from its parent field. This means that non-root fields returning objects, interfaces, or unions that do not specify `maxAge` in some other way does not have the [default `maxAge`](#setting-the-default-maxage) applied. Do not combine this with `maxAge` in the same directive. | Use `@cacheControl` for fields that should always be cached with the same settings. If caching settings might change at runtime, instead use the [dynamic method](#in-your-resolvers-dynamic). @@ -137,20 +139,51 @@ The `setCacheHint` method accepts an object with the same fields as [the `@cache 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 `maxAge` 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` +### Root and composite-type fields are not cachable by default -By default, the following schema fields have a `maxAge` of `0` (meaning their values are _not_ cached unless you specify otherwise): +The general philosophy behind `@cacheControl` is that we should only consider a response to be cachable if we have been told that each piece of it is cachable; we never assume that anything is cachable by default. However, we don't want you to have to specify cache hints for every single field in your entire schema. Ideally, you would specify a cache hint on every field whose resolver reads from a data source such as a database or REST API, based on how long you'd like to cache that particular read operation; fields whose resolvers just read in-memory data fetched by a parent resolver (including the default resolver) don't have a particularly interesting cache policy. -* All **root fields** (i.e., the fields of the `Query` and `Mutation` objects) -* Fields that return an object or interface type +So we follow the following heuristic. By default, the following schema fields have a `maxAge` of `0` (meaning their values are _not_ cached unless you specify otherwise): -Scalar fields inherit their default cache behavior (including `maxAge`) from their parent object type. This enables you to define cache behavior for _most_ scalars at the [type level](#type-level-definitions), while overriding that behavior in individual cases at the [field level](#field-level-definitions). +* All **root fields** (i.e., the fields of the `Query` and `Mutation` objects). Because their parent objects have no data, we guess that they are likely to be doing some sort of non-trivial read operation. +* Fields that **return a composite type** (object, interface, or union), possibly wrapped inside one or more layers of "list of" and "non-null". Our heuristic assumes that these fields (fields with their own sub-fields) are likely to involve a non-trivial read operation, whereas scalar fields are more likely to contain data read in a parent resolver. + +Non-root scalar fields inherit their default cache behavior (including `maxAge`) from their parent object type. This enables you to define cache behavior for _most_ scalars at the [type level](#type-level-definitions), while overriding that behavior in individual cases at the [field level](#field-level-definitions). As a result of these defaults, **no schema fields are cached by default**. +These heuristics aren't always correct. If a (non-root) scalar field does actually perform a read operation with a different cachability from its parent, you can specify a cache hint on it to override the default assumption that non-root scalar fields inherit their parent's cache policy. And if a field returns an object type just as a way of organizing data and not because it's performing a read operation, you can set `@cacheControl(inheritMaxAge: true)` on the field or its return type; in this case, the default `maxAge` of 0 will not be applied. (Setting `@cacheControl(inheritMaxAge: true)` on a root field has no effect. `inheritMaxAge: true` cannot be specified via `info.cacheControl`.) Note that if you specify `@cacheControl(inheritMaxAge: true)` on a type, you may still specify `maxAge` on a field returning that type, which will take effect; and you can specify `maxAge` via `info.cacheControl` even on fields/types with `inheritMaxAge: true`. + +For example, given the following schema: + +```graphql +type Query { + foo: Foo + cachedFoo: Foo @cacheControl(maxAge: 60) + intermediate: Intermediate @cacheControl(maxAge: 40) +} +type Foo { + inheritingField: String + cachedField: String @cacheControl(maxAge: 30) +} +type Intermediate { + foo: Foo @cacheControl(inheritMaxAge: true) +} +``` + +Then the following queries will have the given `maxAge` values: + +| Query | `maxAge` | Explanation | +|-------|----------|-------------| +|`{foo{cachedField}}`|0|is a root field (and an object-typed field) with no `maxAge` and it does not set `maxAge` dynamically, so it defaults to 0. It does not matter that `cachedField` has a `maxAge`.| +|`{cachedFoo{inheritingField}}`|60|`cachedFoo` has a `maxAge` of 60; this means `inheritingField` can follow the normal scalar field rules and not affect `maxAge`.| +|`{cachedFoo{cachedField}}`|30|`cachedFoo` has a `maxAge` of 60 and `cachedField` has a `maxAge` of 30; we take the most restrictive value, 30.| +|`{intermediate{foo{inheritingField}}}`|40|`intermediate` sets its `maxAge` to 40. `Intermediate.foo` has `inheritMaxAge` so it does not affect the cache policy. `Foo.uncachedField` is a scalar so it inherits the `maxAge` from its parent, and thus indirectly from its grandparent: 40.| + + #### Setting the default `maxAge` -You can set a default `maxAge` (instead of `0`) that's applied to every field that doesn't specify a different value. +You can set a default `maxAge` that's applied to the fields that would otherwise receive the default `maxAge` of `0`. That is: fields that don't explicitly specify `maxAge` via `@cacheControl` on the field or the type they return or via `info.cacheControl`, and which are either root fields, or fields that return a composite (object, interface, or union) type and do not have `@cacheControl(inheritMaxAge: true)`. > You should identify and address all exceptions to your default `maxAge` before you enable it in production, but this is a great way to get started with cache control. diff --git a/package-lock.json b/package-lock.json index 388683a3f47..6cb6d5a0c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21014,7 +21014,7 @@ } }, "packages/apollo-datasource": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-caching": "file:../apollo-server-caching", @@ -21025,7 +21025,7 @@ } }, "packages/apollo-datasource-rest": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-datasource": "file:../apollo-datasource", @@ -21042,7 +21042,7 @@ } }, "packages/apollo-reporting-protobuf": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "@apollo/protobufjs": "1.2.2" @@ -21193,7 +21193,7 @@ } }, "packages/apollo-server": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-core": "file:../apollo-server-core", @@ -21209,7 +21209,7 @@ } }, "packages/apollo-server-azure-functions": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@azure/functions": "1.2.3", @@ -21228,7 +21228,7 @@ } }, "packages/apollo-server-cache-memcached": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-caching": "file:../apollo-server-caching", @@ -21240,7 +21240,7 @@ } }, "packages/apollo-server-cache-redis": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-caching": "file:../apollo-server-caching", @@ -21257,7 +21257,7 @@ "license": "MIT" }, "packages/apollo-server-caching": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0" @@ -21281,7 +21281,7 @@ "license": "ISC" }, "packages/apollo-server-cloud-functions": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-core": "file:../apollo-server-core", @@ -21299,7 +21299,7 @@ } }, "packages/apollo-server-cloudflare": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-core": "file:../apollo-server-core", @@ -21311,7 +21311,7 @@ } }, "packages/apollo-server-core": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@apollographql/apollo-tools": "^0.5.1", @@ -21388,7 +21388,7 @@ "license": "ISC" }, "packages/apollo-server-env": { - "version": "3.1.0-alpha.4", + "version": "3.1.0-preview.0", "license": "MIT", "dependencies": { "node-fetch": "^2.6.1", @@ -21512,7 +21512,7 @@ } }, "packages/apollo-server-errors": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "engines": { "node": ">=12.0" @@ -21522,7 +21522,7 @@ } }, "packages/apollo-server-express": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@types/accepts": "^1.3.5", @@ -21563,7 +21563,7 @@ "license": "MIT" }, "packages/apollo-server-fastify": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-core": "file:../apollo-server-core", @@ -21590,7 +21590,7 @@ } }, "packages/apollo-server-hapi": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@hapi/accept": "^5.0.2", @@ -21610,7 +21610,7 @@ } }, "packages/apollo-server-integration-testsuite": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-core": "file:../apollo-server-core", @@ -21621,7 +21621,7 @@ } }, "packages/apollo-server-koa": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@koa/cors": "^3.1.0", @@ -21711,7 +21711,7 @@ } }, "packages/apollo-server-lambda": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@types/aws-lambda": "^8.10.76", @@ -21731,7 +21731,7 @@ } }, "packages/apollo-server-micro": { - "version": "3.0.0-alpha.4", + "version": "3.0.0-preview.0", "license": "MIT", "dependencies": { "@hapi/accept": "^5.0.2", @@ -21744,7 +21744,7 @@ } }, "packages/apollo-server-plugin-base": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-types": "file:../apollo-server-types" @@ -21757,7 +21757,7 @@ } }, "packages/apollo-server-plugin-operation-registry": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-graphql": "0.9.2", @@ -22046,7 +22046,7 @@ "license": "ISC" }, "packages/apollo-server-plugin-response-cache": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-server-caching": "file:../apollo-server-caching", @@ -22063,7 +22063,7 @@ } }, "packages/apollo-server-types": { - "version": "0.300.0-alpha.4", + "version": "0.300.0-preview.0", "license": "MIT", "dependencies": { "apollo-reporting-protobuf": "file:../apollo-reporting-protobuf", 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 57cf991d7de..6050285bbf5 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 @@ -1,7 +1,13 @@ -import { buildSchemaWithCacheControlSupport } from './cacheControlSupport'; +import { + buildSchemaWithCacheControlSupport, + makeExecutableSchemaWithCacheControlSupport, +} from './cacheControlSupport'; import { CacheScope } from 'apollo-server-types'; -import { collectCacheControlHints } from './collectCacheControlHints'; +import { + collectCacheControlHints, + collectCacheControlHintsAndPolicyIfCacheable, +} from './collectCacheControlHints'; describe('@cacheControl directives', () => { it('should set maxAge: 0 and no scope for a field without cache hints', async () => { @@ -270,4 +276,221 @@ describe('@cacheControl directives', () => { new Map([['droid', { maxAge: 60, scope: CacheScope.Private }]]), ); }); + + it('inheritMaxAge', async () => { + const schema = makeExecutableSchemaWithCacheControlSupport({ + typeDefs: `#graphql + type Query { + topLevel: DroidQuery @cacheControl(maxAge: 1000) + } + + type DroidQuery { + droid: Droid @cacheControl(inheritMaxAge: true) + droids: [Droid] @cacheControl(inheritMaxAge: true) + } + + type Droid { + uncachedField: Droid + scalarField: String + cachedField: String @cacheControl(maxAge: 30) + } + `, + }); + + { + const { hints, policyIfCacheable } = + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + '{ topLevel { droid { cachedField } } }', + {}, + ); + + expect(hints).toStrictEqual( + new Map([ + ['topLevel', { maxAge: 1000 }], + ['topLevel.droid.cachedField', { maxAge: 30 }], + ]), + ); + expect(policyIfCacheable).toStrictEqual({ + maxAge: 30, + scope: CacheScope.Public, + }); + } + + { + const { hints, policyIfCacheable } = + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + '{ topLevel { droid { uncachedField { cachedField } cachedField } } }', + {}, + ); + + expect(hints).toStrictEqual( + new Map([ + ['topLevel', { maxAge: 1000 }], + ['topLevel.droid.cachedField', { maxAge: 30 }], + ['topLevel.droid.uncachedField', { maxAge: 0 }], + ['topLevel.droid.uncachedField.cachedField', { maxAge: 30 }], + ]), + ); + expect(policyIfCacheable).toBeNull(); + } + + { + const { hints, policyIfCacheable } = + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + '{ topLevel { droids { uncachedField { cachedField } cachedField } } }', + {}, + ); + + expect(hints).toStrictEqual( + new Map([ + ['topLevel', { maxAge: 1000 }], + ['topLevel.droids.0.cachedField', { maxAge: 30 }], + ['topLevel.droids.0.uncachedField', { maxAge: 0 }], + ['topLevel.droids.0.uncachedField.cachedField', { maxAge: 30 }], + ['topLevel.droids.1.cachedField', { maxAge: 30 }], + ['topLevel.droids.1.uncachedField', { maxAge: 0 }], + ['topLevel.droids.1.uncachedField.cachedField', { maxAge: 30 }], + ]), + ); + expect(policyIfCacheable).toBeNull(); + } + }); + + it('inheritMaxAge docs examples', async () => { + const schema = makeExecutableSchemaWithCacheControlSupport({ + typeDefs: `#graphql + type Query { + foo: Foo + cachedFoo: Foo @cacheControl(maxAge: 60) + intermediate: Intermediate @cacheControl(maxAge: 40) + } + type Foo { + inheritingField: String + cachedField: String @cacheControl(maxAge: 30) + } + type Intermediate { + foo: Foo @cacheControl(inheritMaxAge: true) + } + `, + }); + + async function expectMaxAge(operation: string, maxAge: number | undefined) { + expect( + ( + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + operation, + {}, + ) + ).policyIfCacheable?.maxAge, + ).toBe(maxAge); + } + + await expectMaxAge('{foo{cachedField}}', undefined); + await expectMaxAge('{cachedFoo{inheritingField}}', 60); + await expectMaxAge('{cachedFoo{cachedField}}', 30); + await expectMaxAge('{intermediate{foo{inheritingField}}}', 40); + }); + + it('inheritMaxAge can be combined with scope', async () => { + const schema = makeExecutableSchemaWithCacheControlSupport({ + typeDefs: `#graphql + type Query { + topLevel: TopLevel @cacheControl(maxAge: 500) + } + type TopLevel { + foo: Foo @cacheControl(inheritMaxAge: true, scope: PRIVATE) + } + type Foo { + bar: String @cacheControl(maxAge: 5) + } + `, + }); + + const { hints, policyIfCacheable } = + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + '{topLevel { foo { bar } } }', + {}, + ); + + expect(hints).toStrictEqual( + new Map([ + ['topLevel', { maxAge: 500 }], + ['topLevel.foo', { scope: CacheScope.Private }], + ['topLevel.foo.bar', { maxAge: 5 }], + ]), + ); + expect(policyIfCacheable).toStrictEqual({ + maxAge: 5, + scope: CacheScope.Private, + }); + }); + + it('inheritMaxAge on types', async () => { + const schema = makeExecutableSchemaWithCacheControlSupport({ + typeDefs: `#graphql + type Query { + topLevel: TopLevel @cacheControl(maxAge: 500) + } + type TopLevel { + foo: Foo + } + type Foo @cacheControl(inheritMaxAge: true) { + bar: String + } + `, + }); + + const { hints, policyIfCacheable } = + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + '{topLevel { foo { bar } } }', + {}, + ); + + expect(hints).toStrictEqual(new Map([['topLevel', { maxAge: 500 }]])); + expect(policyIfCacheable).toStrictEqual({ + maxAge: 500, + scope: CacheScope.Public, + }); + }); + + it('scalars can inherit from grandparents', async () => { + const schema = makeExecutableSchemaWithCacheControlSupport({ + typeDefs: `#graphql + type Query { + foo: Foo @cacheControl(maxAge: 5) + } + type Foo { + bar: Bar @cacheControl(inheritMaxAge: true) + defaultBar: Bar + } + type Bar { + scalar: String + cachedScalar: String @cacheControl(maxAge: 2) + } + `, + }); + + async function expectMaxAge(operation: string, maxAge: number | undefined) { + expect( + ( + await collectCacheControlHintsAndPolicyIfCacheable( + schema, + operation, + {}, + ) + ).policyIfCacheable?.maxAge, + ).toBe(maxAge); + } + + await expectMaxAge('{foo{defaultBar{scalar}}}', undefined); + await expectMaxAge('{foo{defaultBar{cachedScalar}}}', undefined); + await expectMaxAge('{foo{bar{scalar}}}', 5); + await expectMaxAge('{foo{bar{cachedScalar}}}', 2); + }); }); diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts index fe550c07538..c2877849360 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts @@ -3,6 +3,7 @@ import { makeExecutableSchema, IExecutableSchemaDefinition, } from '@graphql-tools/schema'; +import { addMocksToSchema } from '@graphql-tools/mock'; export function augmentTypeDefsWithCacheControlSupport(typeDefs: string) { return ( @@ -15,7 +16,8 @@ export function augmentTypeDefsWithCacheControlSupport(typeDefs: string) { directive @cacheControl( maxAge: Int scope: CacheControlScope - ) on FIELD_DEFINITION | OBJECT | INTERFACE + inheritMaxAge: Boolean + ) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION ` + typeDefs ); } @@ -27,8 +29,11 @@ export function buildSchemaWithCacheControlSupport(source: string) { export function makeExecutableSchemaWithCacheControlSupport( options: IExecutableSchemaDefinition & { typeDefs: string }, ) { - return makeExecutableSchema({ - ...options, - typeDefs: augmentTypeDefsWithCacheControlSupport(options.typeDefs), + return addMocksToSchema({ + schema: makeExecutableSchema({ + ...options, + typeDefs: augmentTypeDefsWithCacheControlSupport(options.typeDefs), + }), + preserveResolvers: true, }); } diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts index 3e91c130344..d6d017eeaf2 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts @@ -6,11 +6,14 @@ import { } from '../'; import pluginTestHarness from '../../../utils/pluginTestHarness'; -export async function collectCacheControlHints( +export async function collectCacheControlHintsAndPolicyIfCacheable( schema: GraphQLSchema, source: string, options: ApolloServerPluginCacheControlOptions = {}, -): Promise> { +): Promise<{ + hints: Map; + policyIfCacheable: Required | null; +}> { const cacheHints = new Map(); const pluginInstance = ApolloServerPluginCacheControl({ ...options, @@ -34,5 +37,14 @@ export async function collectCacheControlHints( expect(requestContext.response.errors).toBeUndefined(); - return cacheHints; + return { + hints: cacheHints, + policyIfCacheable: requestContext.overallCachePolicy.policyIfCacheable(), + }; +} + +export async function collectCacheControlHints( + ...args: Parameters +): Promise> { + return (await collectCacheControlHintsAndPolicyIfCacheable(...args)).hints; } diff --git a/packages/apollo-server-core/src/plugin/cacheControl/index.ts b/packages/apollo-server-core/src/plugin/cacheControl/index.ts index a5d8134de25..d83e47e27c0 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/index.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/index.ts @@ -1,12 +1,14 @@ -import type { CacheHint, CachePolicy } from 'apollo-server-types'; +import type { + CacheAnnotation, + CacheHint, + CachePolicy, +} from 'apollo-server-types'; import { CacheScope } from 'apollo-server-types'; import { DirectiveNode, getNamedType, GraphQLCompositeType, GraphQLField, - GraphQLInterfaceType, - GraphQLObjectType, isCompositeType, isInterfaceType, isObjectType, @@ -50,32 +52,37 @@ declare module 'graphql/type/definition' { export function ApolloServerPluginCacheControl( options: ApolloServerPluginCacheControlOptions = Object.create(null), ): InternalApolloServerPlugin { - const typeCacheHintCache = new LRUCache(); - const fieldCacheHintCache = new LRUCache< + const typeAnnotationCache = new LRUCache< + GraphQLCompositeType, + CacheAnnotation + >(); + const fieldAnnotationCache = new LRUCache< GraphQLField, - CacheHint + CacheAnnotation >(); - function memoizedCacheHintFromType(t: GraphQLCompositeType): CacheHint { - const cachedHint = typeCacheHintCache.get(t); - if (cachedHint) { - return cachedHint; + function memoizedCacheAnnotationFromType( + t: GraphQLCompositeType, + ): CacheAnnotation { + const existing = typeAnnotationCache.get(t); + if (existing) { + return existing; } - const hint = cacheHintFromType(t); - typeCacheHintCache.set(t, hint); - return hint; + const annotation = cacheAnnotationFromType(t); + typeAnnotationCache.set(t, annotation); + return annotation; } - function memoizedCacheHintFromField( + function memoizedCacheAnnotationFromField( field: GraphQLField, - ): CacheHint { - const cachedHint = fieldCacheHintCache.get(field); - if (cachedHint) { - return cachedHint; + ): CacheAnnotation { + const existing = fieldAnnotationCache.get(field); + if (existing) { + return existing; } - const hint = cacheHintFromField(field); - fieldCacheHintCache.set(field, hint); - return hint; + const annotation = cacheAnnotationFromField(field); + fieldAnnotationCache.set(field, annotation); + return annotation; } return { @@ -91,10 +98,10 @@ export function ApolloServerPluginCacheControl( // gateway. (Once https://github.com/apollographql/apollo-server/pull/5187 // lands we should also run this code from a schemaDidLoadOrUpdate // callback.) - typeCacheHintCache.max = Object.values(schema.getTypeMap()).filter( + typeAnnotationCache.max = Object.values(schema.getTypeMap()).filter( isCompositeType, ).length; - fieldCacheHintCache.max = + fieldAnnotationCache.max = Object.values(schema.getTypeMap()) .filter(isObjectType) .flatMap((t) => Object.values(t.getFields())).length + @@ -130,7 +137,7 @@ export function ApolloServerPluginCacheControl( fakeFieldPolicy.replace(dynamicHint); }, cacheHint: fakeFieldPolicy, - cacheHintFromType: memoizedCacheHintFromType, + cacheHintFromType: memoizedCacheAnnotationFromType, }; }, }; @@ -140,41 +147,62 @@ export function ApolloServerPluginCacheControl( willResolveField({ info }) { const fieldPolicy = newCachePolicy(); - // If this field's resolver returns an object or interface, look for - // hints on that return type. - // XXX This should also handled union-valued fields; going to deal - // with this in a separate PR. + 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 ( - targetType instanceof GraphQLObjectType || - targetType instanceof GraphQLInterfaceType - ) { - fieldPolicy.replace(memoizedCacheHintFromType(targetType)); + 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. - fieldPolicy.replace( - memoizedCacheHintFromField( - info.parentType.getFields()[info.fieldName], - ), + const fieldAnnotation = memoizedCacheAnnotationFromField( + 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 - // 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.) - // XXX This should also handled union-valued fields; going to deal - // with this in a separate PR. + // 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 ( - (targetType instanceof GraphQLObjectType || - targetType instanceof GraphQLInterfaceType || - !info.path.prev) && + 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); + } + + // 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. + if ( + fieldPolicy.maxAge === undefined && + ((isCompositeType(targetType) && !inheritMaxAge) || + !info.path.prev) ) { fieldPolicy.restrict({ maxAge: defaultMaxAge }); } @@ -184,12 +212,13 @@ export function ApolloServerPluginCacheControl( fieldPolicy.replace(dynamicHint); }, cacheHint: fieldPolicy, - cacheHintFromType, + cacheHintFromType: cacheAnnotationFromType, }; - // 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. + // 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 (__testing__cacheHints && isRestricted(fieldPolicy)) { const path = responsePathAsArray(info.path).join('.'); @@ -236,9 +265,9 @@ export function ApolloServerPluginCacheControl( }; } -function cacheHintFromDirectives( +function cacheAnnotationFromDirectives( directives: ReadonlyArray | undefined, -): CacheHint | undefined { +): CacheAnnotation | undefined { if (!directives) return undefined; const cacheControlDirective = directives.find( @@ -254,34 +283,42 @@ function cacheHintFromDirectives( 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 }; + } - // TODO: Add proper typechecking of arguments return { maxAge: - maxAgeArgument && - maxAgeArgument.value && - maxAgeArgument.value.kind === 'IntValue' + maxAgeArgument?.value?.kind === 'IntValue' ? parseInt(maxAgeArgument.value.value) : undefined, - scope: - scopeArgument && - scopeArgument.value && - scopeArgument.value.kind === 'EnumValue' - ? (scopeArgument.value.value as CacheScope) - : undefined, + scope, }; } -function cacheHintFromType(t: GraphQLCompositeType): CacheHint { +function cacheAnnotationFromType(t: GraphQLCompositeType): CacheAnnotation { if (t.astNode) { - const hint = cacheHintFromDirectives(t.astNode.directives); + const hint = cacheAnnotationFromDirectives(t.astNode.directives); if (hint) { return hint; } } if (t.extensionASTNodes) { for (const node of t.extensionASTNodes) { - const hint = cacheHintFromDirectives(node.directives); + const hint = cacheAnnotationFromDirectives(node.directives); if (hint) { return hint; } @@ -290,9 +327,11 @@ function cacheHintFromType(t: GraphQLCompositeType): CacheHint { return {}; } -function cacheHintFromField(field: GraphQLField): CacheHint { +function cacheAnnotationFromField( + field: GraphQLField, +): CacheAnnotation { if (field.astNode) { - const hint = cacheHintFromDirectives(field.astNode.directives); + const hint = cacheAnnotationFromDirectives(field.astNode.directives); if (hint) { return hint; } diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index 9f90cd3d02f..cdcdf1c78c3 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -254,6 +254,16 @@ export interface CacheHint { scope?: CacheScope; } +/** + * 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.) + */ +export interface CacheAnnotation extends CacheHint { + inheritMaxAge?: true; +} + export enum CacheScope { Public = 'PUBLIC', Private = 'PRIVATE',