diff --git a/CHANGELOG.md b/CHANGELOG.md index b1aee8a1900..61132a08c98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,52 @@ The version headers in this history reflect the versions of Apollo Server itself - _Nothing yet! Stay tuned!_ +### v2.14.0 + +- `apollo-server-core` / `apollo-server-plugin-base`: Add support for `willResolveField` and corresponding end-handler within `executionDidStart`. This brings the remaining bit of functionality that was previously only available from `graphql-extensions` to the new plugin API. The `graphql-extensions` API (which was never documented) will be deprecated in Apollo Server 3.x. To see the documentation for the request pipeline API, see [its documentation](https://www.apollographql.com/docs/apollo-server/integrations/plugins/). For more details, see the attached PR. [PR #3988](https://github.com/apollographql/apollo-server/pull/3988) +- `apollo-server-core`: Deprecate `graphql-extensions`. All internal usages of the `graphql-extensions` API have been migrated to the request pipeline plugin API. For any implementor-supplied `extensions`, a deprecation warning will be printed once per-extension, per-server-startup, notifying of the intention to deprecate. Extensions should migrate to the plugin API, which is outlined in [its documentation](https://www.apollographql.com/docs/apollo-server/integrations/plugins/). [PR #4135](https://github.com/apollographql/apollo-server/pull/4135) +- `apollo-engine-reporting`: **Currently only for non-federated graphs.** + Added an _experimental_ schema reporting option, + `experimental_schemaReporting`, for Apollo Graph Manager users. **During + this experiment, we'd appreciate testing and feedback from current and new + users of the schema registry!** + + Prior to the introduction of this feature, the only way to get schemas into + the schema registry in Apollo Graph Manager was to use the CLI and run + `apollo schema:push`. _Apollo schema reporting protocol_ is a *new* + specification for GraphQL servers to automatically report schemas to the + Apollo Graph Manager schema registry. + + **To enable schema reporting,** provide a Graph Manager API key (available + free from [Apollo Graph Manager](https://engine.apollographql.com/)) in the + `APOLLO_KEY` environment variable *and* set the `experimental_schemaReporting` + option to `true` in the Apollo Server constructor options, like so: + + ```js + const server = new ApolloServer({ + typeDefs, + resolvers, + engine: { + experimental_schemaReporting: true, + /* Other existing options can remain the same. */ + }, + }); + ``` + + > When enabled, a schema reporter is initiated by the `apollo-engine-reporting` agent. It will loop until the `ApolloServer` instance is stopped, periodically calling back to Apollo Graph Manager to send information. The life-cycle of this reporter is managed by the agent. + + For more details on the implementation of this new protocol, see the PR which + introduced it to Apollo Server and the [preview documentation](https://github.com/apollographql/apollo-schema-reporting-preview-docs). + + [PR #4084](https://github.com/apollographql/apollo-server/pull/4084) +- `apollo-engine-reporting`: The underlying integration of this plugin, which instruments and traces the graph's resolver performance and transmits these metrics to [Apollo Graph Manager](https://engine.apollographql.com/), has been changed from the (soon to be deprecated) `graphql-extensions` API to the new [request pipeline `plugins` API](https://www.apollographql.com/docs/apollo-server/integrations/plugins/). [PR #3998](https://github.com/apollographql/apollo-server/pull/3998) + + _This change should be purely an implementation detail for a majority of users_. There are, however, some special considerations which are worth noting: + + - The federated tracing plugin's `ftv1` response on `extensions` (which is present on the response from an implementing service to the gateway) is now placed on the `extensions` _after_ the `formatResponse` hook. Anyone leveraging the `extensions`.`ftv1` data from the `formatResponse` hook will find that it is no longer present at that phase. +- `apollo-tracing`: This package's internal integration with Apollo Server has been switched from using the soon-to-be-deprecated `graphql-extensions` API to using [the request pipeline plugin API](https://www.apollographql.com/docs/apollo-server/integrations/plugins/). Behavior should remain otherwise the same. [PR #3991](https://github.com/apollographql/apollo-server/pull/3991) +- `apollo-cache-control`: This package's internal integration with Apollo Server has been switched from using the soon-to-be-deprecated `graphql-extensions` API to using [the request pipeline plugin API](https://www.apollographql.com/docs/apollo-server/integrations/plugins/). Behavior should remain otherwise the same. [PR #3997](https://github.com/apollographql/apollo-server/pull/3997) + ### v2.13.0 > [See complete versioning details.](https://github.com/apollographql/apollo-server/commit/e37384a49b2bf474eed0de3e9f4a1bebaeee64c7) diff --git a/docs/source/api/apollo-server.md b/docs/source/api/apollo-server.md index c5ba9fc92a6..3d3f142f8d7 100644 --- a/docs/source/api/apollo-server.md +++ b/docs/source/api/apollo-server.md @@ -404,7 +404,7 @@ addMockFunctionsToSchema({ - `{ none: true }`: Don't send any variable values. **(DEFAULT)** - `{ all: true }`: Send all variable values. - - `{ transform: ({ variables, operationString}) => { ... } }`: A custom function for modifying variable values. Keys added by the custom function will be removed, and keys removed will be added back with an empty value. For security reasons, if an error occurs within this function, all variable values will be replaced with `[PREDICATE_FUNCTION_ERROR]`. + - `{ transform: ({ variables, operationString}) => { ... } }`: A custom function for modifying variable values. Keys added by the custom function will be removed, and keys removed will be added back with an empty value. For security reasons, if an error occurs within this function, all variable values will be replaced with `[PREDICATE_FUNCTION_ERROR]`. - `{ exceptNames: [...] }`: A case-sensitive list of names of variables whose values should not be sent to Apollo servers. - `{ onlyNames: [...] }`: A case-sensitive list of names of variables whose values will be sent to Apollo servers. @@ -472,7 +472,13 @@ addMockFunctionsToSchema({ * `schemaTag`: String - A human-readable name to tag this variant of a schema (i.e. staging, EU). Setting this value will cause metrics to be segmented in the Apollo Platform's UI. Additionally schema validation with a schema tag will only check metrics associate with the same string. + > Will be deprecated in 3.0. Use the option `graphVariant` instead. + + A human-readable name to tag this variant of a schema (i.e. staging, EU). Setting this value will cause metrics to be segmented in the Apollo Platform's UI. Additionally schema validation with a schema tag will only check metrics associated with the same string. + +* `graphVariant`: String + + A human-readable name for the variant of a schema (i.e. staging, EU). Setting this value will cause metrics to be segmented in the Apollo Graph Manager UI. Additionally schema validation with a graph variant will only check metrics associated with the same string. * `generateClientInfo`: (GraphQLRequestContext) => ClientInfo **AS 2.2** diff --git a/docs/source/integrations/plugins.md b/docs/source/integrations/plugins.md index df4003d8544..578e62f6a3e 100644 --- a/docs/source/integrations/plugins.md +++ b/docs/source/integrations/plugins.md @@ -121,17 +121,18 @@ The following diagram illustrates the sequence of events that fire for each requ ```mermaid graph TB; - request(requestDidStart) --> parsing(parsingDidStart*); + request(requestDidStart) --> resolveSource(didResolveSource); + resolveSource --"Success"--> parsing(parsingDidStart*); parsing --"Success"--> validation(validationDidStart*); - validation --"Success"--> resolve(didResolveOperation); - resolve --"Success"--> response(responseForOperation); + validation --"Success"--> resolveOperation(didResolveOperation); + resolveOperation --"Success"--> response(responseForOperation); execution(executionDidStart*); errors(didEncounterErrors); response --"Response provided"--> send; response --"No response provided"--> execution; execution --"Success"--> send(willSendResponse); - execution & resolve & parsing & validation --"Failure"--> errors; + execution & resolveSource & resolveOperation & parsing & validation --"Failure"--> errors; errors --> send; class server,request secondary; ``` @@ -313,6 +314,23 @@ should not return a value. > If you're using TypeScript to create your plugin, implement the [ `GraphQLRequestListener` interface](https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-plugin-base/src/index.ts) from the `apollo-server-plugin-base` module to define functions for request lifecycle events. +### `didResolveSource` + +The `didResolveSource` event is invoked after Apollo Server has determined the +`String`-representation of the incoming operation that it will act upon. In the +event that this `String` was not directly passed in from the client, this +may be retrieved from a cache store (e.g., Automated Persisted Queries). + +At this stage, there is not a guarantee that the operation is not malformed. + +```typescript +didResolveSource?( + requestContext: WithRequired< + GraphQLRequestContext, 'source' | 'logger'>, + >, +): ValueOrPromise; +``` + ### `parsingDidStart` The `parsingDidStart` event fires whenever Apollo Server will parse a GraphQL diff --git a/package-lock.json b/package-lock.json index cc3d8a2e10a..1f04675f2b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5434,6 +5434,12 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-PUdqTZVrNYTNcIhLHkiaYzoOIaUi5LFg/XLerAdgvwQrUCx+oSbtoBze1AMyvYbcwzUSNC+Isl58SM4Sm/6COw==", + "dev": true + }, "@types/ws": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.2.4.tgz", @@ -5620,7 +5626,7 @@ "version": "file:packages/apollo-cache-control", "requires": { "apollo-server-env": "file:packages/apollo-server-env", - "graphql-extensions": "file:packages/graphql-extensions" + "apollo-server-plugin-base": "file:packages/apollo-server-plugin-base" } }, "apollo-datasource": { @@ -5648,9 +5654,17 @@ "apollo-server-caching": "file:packages/apollo-server-caching", "apollo-server-env": "file:packages/apollo-server-env", "apollo-server-errors": "file:packages/apollo-server-errors", + "apollo-server-plugin-base": "file:packages/apollo-server-plugin-base", "apollo-server-types": "file:packages/apollo-server-types", "async-retry": "^1.2.1", - "graphql-extensions": "file:packages/graphql-extensions" + "uuid": "^8.0.0" + }, + "dependencies": { + "uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==" + } } }, "apollo-engine-reporting-protobuf": { @@ -6081,7 +6095,7 @@ "version": "file:packages/apollo-tracing", "requires": { "apollo-server-env": "file:packages/apollo-server-env", - "graphql-extensions": "file:packages/graphql-extensions" + "apollo-server-plugin-base": "file:packages/apollo-server-plugin-base" } }, "apollo-utilities": { diff --git a/package.json b/package.json index d7f830c11e6..3f8bfaa5376 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@types/supertest": "^2.0.8", "@types/test-listen": "1.1.0", "@types/type-is": "1.6.3", + "@types/uuid": "^7.0.3", "@types/ws": "7.2.4", "apollo-fetch": "0.7.0", "apollo-link": "1.2.14", diff --git a/packages/apollo-cache-control/package.json b/packages/apollo-cache-control/package.json index 630c1e8d983..b0366f16e2a 100644 --- a/packages/apollo-cache-control/package.json +++ b/packages/apollo-cache-control/package.json @@ -1,6 +1,6 @@ { "name": "apollo-cache-control", - "version": "0.10.1-alpha.0", + "version": "0.11.0", "description": "A GraphQL extension for cache control", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -12,7 +12,7 @@ }, "dependencies": { "apollo-server-env": "file:../apollo-server-env", - "graphql-extensions": "file:../graphql-extensions" + "apollo-server-plugin-base": "file:../apollo-server-plugin-base" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" diff --git a/packages/apollo-cache-control/src/__tests__/cacheControlExtension.test.ts b/packages/apollo-cache-control/src/__tests__/cacheControlExtension.test.ts deleted file mode 100644 index 29a6e0d195d..00000000000 --- a/packages/apollo-cache-control/src/__tests__/cacheControlExtension.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ResponsePath, GraphQLError } from 'graphql'; -import { GraphQLResponse } from 'graphql-extensions'; -import { Headers } from 'apollo-server-env'; -import { CacheControlExtension, CacheScope } from '../'; - -describe('CacheControlExtension', () => { - let cacheControlExtension: CacheControlExtension; - - beforeEach(() => { - cacheControlExtension = new CacheControlExtension(); - }); - - describe('willSendResponse', () => { - let graphqlResponse: GraphQLResponse; - - beforeEach(() => { - cacheControlExtension.options.calculateHttpHeaders = true; - cacheControlExtension.computeOverallCachePolicy = () => ({ - maxAge: 300, - scope: CacheScope.Public, - }); - graphqlResponse = { - http: { - headers: new Headers(), - }, - data: { test: 'test' }, - }; - }); - - it('sets cache-control header', () => { - cacheControlExtension.willSendResponse && - cacheControlExtension.willSendResponse({ graphqlResponse }); - expect(graphqlResponse.http!.headers.get('Cache-Control')).toBe( - 'max-age=300, public', - ); - }); - - const shouldNotSetCacheControlHeader = () => { - cacheControlExtension.willSendResponse && - cacheControlExtension.willSendResponse({ graphqlResponse }); - expect(graphqlResponse.http!.headers.get('Cache-Control')).toBeNull(); - }; - - it('does not set cache-control header if calculateHttpHeaders is set to false', () => { - cacheControlExtension.options.calculateHttpHeaders = false; - shouldNotSetCacheControlHeader(); - }); - - it('does not set cache-control header if graphqlResponse has errors', () => { - graphqlResponse.errors = [new GraphQLError('Test Error')]; - shouldNotSetCacheControlHeader(); - }); - - it('does not set cache-control header if there is no overall cache policy', () => { - cacheControlExtension.computeOverallCachePolicy = () => undefined; - shouldNotSetCacheControlHeader(); - }); - }); - - describe('computeOverallCachePolicy', () => { - const responsePath: ResponsePath = { - key: 'test', - prev: undefined, - }; - const responseSubPath: ResponsePath = { - key: 'subTest', - prev: responsePath, - }; - const responseSubSubPath: ResponsePath = { - key: 'subSubTest', - prev: responseSubPath, - }; - - it('returns undefined without cache hints', () => { - const cachePolicy = cacheControlExtension.computeOverallCachePolicy(); - expect(cachePolicy).toBeUndefined(); - }); - - it('returns lowest max age value', () => { - cacheControlExtension.addHint(responsePath, { maxAge: 10 }); - cacheControlExtension.addHint(responseSubPath, { maxAge: 20 }); - - const cachePolicy = cacheControlExtension.computeOverallCachePolicy(); - expect(cachePolicy).toHaveProperty('maxAge', 10); - }); - - it('returns undefined if any cache hint has a maxAge of 0', () => { - cacheControlExtension.addHint(responsePath, { maxAge: 120 }); - cacheControlExtension.addHint(responseSubPath, { maxAge: 0 }); - cacheControlExtension.addHint(responseSubSubPath, { maxAge: 20 }); - - const cachePolicy = cacheControlExtension.computeOverallCachePolicy(); - expect(cachePolicy).toBeUndefined(); - }); - - it('returns PUBLIC scope by default', () => { - cacheControlExtension.addHint(responsePath, { maxAge: 10 }); - - const cachePolicy = cacheControlExtension.computeOverallCachePolicy(); - expect(cachePolicy).toHaveProperty('scope', CacheScope.Public); - }); - - it('returns PRIVATE scope if any cache hint has PRIVATE scope', () => { - cacheControlExtension.addHint(responsePath, { - maxAge: 10, - scope: CacheScope.Public, - }); - cacheControlExtension.addHint(responseSubPath, { - maxAge: 10, - scope: CacheScope.Private, - }); - - const cachePolicy = cacheControlExtension.computeOverallCachePolicy(); - expect(cachePolicy).toHaveProperty('scope', CacheScope.Private); - }); - }); -}); diff --git a/packages/apollo-cache-control/src/__tests__/cacheControlPlugin.test.ts b/packages/apollo-cache-control/src/__tests__/cacheControlPlugin.test.ts new file mode 100644 index 00000000000..a29cbbf02b1 --- /dev/null +++ b/packages/apollo-cache-control/src/__tests__/cacheControlPlugin.test.ts @@ -0,0 +1,171 @@ +import { ResponsePath, GraphQLError } from 'graphql'; +import { Headers } from 'apollo-server-env'; +import { + CacheScope, + CacheControlExtensionOptions, + CacheHint, + __testing__, + plugin, +} from '../'; +const { addHint, computeOverallCachePolicy } = __testing__; +import { + GraphQLRequestContextWillSendResponse, + GraphQLResponse, +} from 'apollo-server-plugin-base'; +import pluginTestHarness from 'apollo-server-core/dist/utils/pluginTestHarness'; + +describe('plugin', () => { + describe('willSendResponse', () => { + function makePluginWithOptions({ + pluginInitializationOptions, + overallCachePolicy, + errors = false, + }: { + pluginInitializationOptions?: CacheControlExtensionOptions; + overallCachePolicy?: Required; + errors?: boolean; + } = Object.create(null)) { + const pluginInstance = plugin(pluginInitializationOptions); + + return pluginTestHarness({ + pluginInstance, + overallCachePolicy, + graphqlRequest: { query: 'does not matter' }, + executor: () => { + const response: GraphQLResponse = { + http: { + headers: new Headers(), + }, + data: { test: 'test' }, + }; + + if (errors) { + response.errors = [new GraphQLError('Test Error')]; + } + + return response; + }, + }); + } + + describe('HTTP cache-control header', () => { + const overallCachePolicy: Required = { + maxAge: 300, + scope: CacheScope.Public, + }; + + it('is set when calculateHttpHeaders is set to true', async () => { + const requestContext = await makePluginWithOptions({ + pluginInitializationOptions: { + calculateHttpHeaders: true, + }, + overallCachePolicy, + }); + expect(requestContext.response.http!.headers.get('Cache-Control')).toBe( + 'max-age=300, public', + ); + }); + + const shouldNotSetCacheControlHeader = ( + requestContext: GraphQLRequestContextWillSendResponse, + ) => { + expect( + requestContext.response.http!.headers.get('Cache-Control'), + ).toBeNull(); + }; + + it('is not set when calculateHttpHeaders is set to false', async () => { + const requestContext = await makePluginWithOptions({ + pluginInitializationOptions: { + calculateHttpHeaders: false, + }, + overallCachePolicy, + }); + shouldNotSetCacheControlHeader(requestContext); + }); + + it('is not set if response has errors', async () => { + const requestContext = await makePluginWithOptions({ + pluginInitializationOptions: { + calculateHttpHeaders: false, + }, + overallCachePolicy, + errors: true, + }); + shouldNotSetCacheControlHeader(requestContext); + }); + + it('does not set cache-control header if there is no overall cache policy', async () => { + const requestContext = await makePluginWithOptions({ + pluginInitializationOptions: { + calculateHttpHeaders: false, + }, + overallCachePolicy: undefined, + errors: true, + }); + shouldNotSetCacheControlHeader(requestContext); + }); + }); + }); + + describe('computeOverallCachePolicy', () => { + const responsePath: ResponsePath = { + key: 'test', + prev: undefined, + }; + const responseSubPath: ResponsePath = { + key: 'subTest', + prev: responsePath, + }; + const responseSubSubPath: ResponsePath = { + key: 'subSubTest', + prev: responseSubPath, + }; + + const hints = new Map(); + afterEach(() => hints.clear()); + + it('returns undefined without cache hints', () => { + const cachePolicy = computeOverallCachePolicy(hints); + expect(cachePolicy).toBeUndefined(); + }); + + it('returns lowest max age value', () => { + addHint(hints, responsePath, { maxAge: 10 }); + addHint(hints, responseSubPath, { maxAge: 20 }); + + const cachePolicy = computeOverallCachePolicy(hints); + expect(cachePolicy).toHaveProperty('maxAge', 10); + }); + + it('returns undefined if any cache hint has a maxAge of 0', () => { + addHint(hints, responsePath, { maxAge: 120 }); + addHint(hints, responseSubPath, { maxAge: 0 }); + addHint(hints, responseSubSubPath, { maxAge: 20 }); + + const cachePolicy = computeOverallCachePolicy(hints); + expect(cachePolicy).toBeUndefined(); + }); + + it('returns PUBLIC scope by default', () => { + addHint(hints, responsePath, { maxAge: 10 }); + + const cachePolicy = computeOverallCachePolicy(hints); + expect(cachePolicy).toHaveProperty('scope', CacheScope.Public); + }); + + it('returns PRIVATE scope if any cache hint has PRIVATE scope', () => { + addHint(hints, responsePath, { + maxAge: 10, + scope: CacheScope.Public, + }); + addHint(hints, responseSubPath, { + maxAge: 10, + scope: CacheScope.Private, + }); + + const cachePolicy = computeOverallCachePolicy(hints); + expect(cachePolicy).toHaveProperty('scope', CacheScope.Private); + }); + }); +}); diff --git a/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts b/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts index e69e8bc448e..fdadf7d7ca4 100644 --- a/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts +++ b/packages/apollo-cache-control/src/__tests__/collectCacheControlHints.ts @@ -1,38 +1,41 @@ import { GraphQLSchema, graphql } from 'graphql'; - -import { - enableGraphQLExtensions, - GraphQLExtensionStack, -} from 'graphql-extensions'; import { - CacheControlExtension, CacheHint, CacheControlExtensionOptions, + plugin, } from '../'; +import pluginTestHarness from 'apollo-server-core/dist/utils/pluginTestHarness'; export async function collectCacheControlHints( schema: GraphQLSchema, source: string, options?: CacheControlExtensionOptions, ): Promise { - enableGraphQLExtensions(schema); // Because this test helper looks at the formatted extensions, we always want - // to include them. - const cacheControlExtension = new CacheControlExtension({ + // to include them in the response rather than allow them to be stripped + // out. + const pluginInstance = plugin({ ...options, stripFormattedExtensions: false, }); - const response = await graphql({ + const requestContext = await pluginTestHarness({ + pluginInstance, schema, - source, - contextValue: { - _extensionStack: new GraphQLExtensionStack([cacheControlExtension]), + graphqlRequest: { + query: source, }, + executor: async (requestContext) => { + return await graphql({ + schema, + source: requestContext.request.query, + contextValue: requestContext.context, + }); + } }); - expect(response.errors).toBeUndefined(); + expect(requestContext.response.errors).toBeUndefined(); - return cacheControlExtension.format()[1].hints; + return requestContext.response.extensions!.cacheControl.hints; } diff --git a/packages/apollo-cache-control/src/index.ts b/packages/apollo-cache-control/src/index.ts index fa37370f03e..53f8e4272fd 100644 --- a/packages/apollo-cache-control/src/index.ts +++ b/packages/apollo-cache-control/src/index.ts @@ -3,12 +3,10 @@ import { getNamedType, GraphQLInterfaceType, GraphQLObjectType, - GraphQLResolveInfo, ResponsePath, responsePathAsArray, } from 'graphql'; - -import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions'; +import { ApolloServerPlugin } from "apollo-server-plugin-base"; export interface CacheControlFormat { version: 1; @@ -49,164 +47,148 @@ declare module 'apollo-server-types' { } } -export class CacheControlExtension - implements GraphQLExtension { - private defaultMaxAge: number; - - constructor(public options: CacheControlExtensionOptions = {}) { - this.defaultMaxAge = options.defaultMaxAge || 0; - } - - private hints: Map = new Map(); - private overallCachePolicyOverride?: Required; - - willResolveField( - _source: any, - _args: { [argName: string]: any }, - _context: TContext, - info: GraphQLResolveInfo, - ) { - let hint: CacheHint = {}; - - // If this field's resolver returns an object or interface, look for hints - // on that return type. - const targetType = getNamedType(info.returnType); - if ( - targetType instanceof GraphQLObjectType || - targetType instanceof GraphQLInterfaceType - ) { - if (targetType.astNode) { - hint = mergeHints( - hint, - cacheHintFromDirectives(targetType.astNode.directives), - ); - } - } - - // 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), - ); - } - - // 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.) - if ( - (targetType instanceof GraphQLObjectType || - targetType instanceof GraphQLInterfaceType || - !info.path.prev) && - hint.maxAge === undefined - ) { - hint.maxAge = this.defaultMaxAge; - } +type MapResponsePathHints = Map; - if (hint.maxAge !== undefined || hint.scope !== undefined) { - this.addHint(info.path, hint); - } +export const plugin = ( + options: CacheControlExtensionOptions = Object.create(null), +): ApolloServerPlugin => ({ + requestDidStart(requestContext) { + const defaultMaxAge: number = options.defaultMaxAge || 0; + const hints: MapResponsePathHints = new Map(); - info.cacheControl = { - setCacheHint: (hint: CacheHint) => { - this.addHint(info.path, hint); - }, - cacheHint: hint, - }; - } - addHint(path: ResponsePath, hint: CacheHint) { - const existingCacheHint = this.hints.get(path); - if (existingCacheHint) { - this.hints.set(path, mergeHints(existingCacheHint, hint)); - } else { - this.hints.set(path, hint); + function setOverallCachePolicyWhenUnset() { + if (!requestContext.overallCachePolicy) { + requestContext.overallCachePolicy = computeOverallCachePolicy(hints); + } } - } - format(): [string, CacheControlFormat] | undefined { - // We should have to explicitly ask to leave the formatted extension in, or - // pass the old-school `cacheControl: true` (as interpreted by - // apollo-server-core/ApolloServer), in order to include the - // engineproxy-aimed extensions. Specifically, we want users of - // apollo-server-plugin-response-cache to be able to specify - // `cacheControl: {defaultMaxAge: 600}` without accidentally turning on the - // extension formatting. - if (this.options.stripFormattedExtensions !== false) return; - - return [ - 'cacheControl', - { - version: 1, - hints: Array.from(this.hints).map(([path, hint]) => ({ - path: [...responsePathAsArray(path)], - ...hint, - })), + return { + executionDidStart: () => ({ + executionDidEnd: () => setOverallCachePolicyWhenUnset(), + willResolveField({ info }) { + let hint: CacheHint = {}; + + // If this field's resolver returns an object or interface, look for + // hints on that return type. + const targetType = getNamedType(info.returnType); + if ( + targetType instanceof GraphQLObjectType || + targetType instanceof GraphQLInterfaceType + ) { + if (targetType.astNode) { + hint = mergeHints( + hint, + cacheHintFromDirectives(targetType.astNode.directives), + ); + } + } + + // 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), + ); + } + + // 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.) + if ( + (targetType instanceof GraphQLObjectType || + targetType instanceof GraphQLInterfaceType || + !info.path.prev) && + hint.maxAge === undefined + ) { + hint.maxAge = defaultMaxAge; + } + + if (hint.maxAge !== undefined || hint.scope !== undefined) { + addHint(hints, info.path, hint); + } + + info.cacheControl = { + setCacheHint: (hint: CacheHint) => { + addHint(hints, info.path, hint); + }, + cacheHint: hint, + }; + }, + }), + + responseForOperation() { + // We are not supplying an answer, we are only setting the cache + // policy if it's not set! Therefore, we return null. + setOverallCachePolicyWhenUnset(); + return null; }, - ]; - } - public willSendResponse?(o: { graphqlResponse: GraphQLResponse }) { - if ( - !this.options.calculateHttpHeaders || - !o.graphqlResponse.http || - o.graphqlResponse.errors - ) { - return; - } + willSendResponse(requestContext) { + const { + response, + overallCachePolicy: overallCachePolicyOverride, + } = requestContext; - const overallCachePolicy = this.computeOverallCachePolicy(); + // If there are any errors, we don't consider this cacheable. + if (response.errors) { + return; + } - if (overallCachePolicy) { - o.graphqlResponse.http.headers.set( - 'Cache-Control', - `max-age=${ - overallCachePolicy.maxAge - }, ${overallCachePolicy.scope.toLowerCase()}`, - ); - } - } + // Use the override by default, but if it's not overridden, set our + // own computation onto the `requestContext` for other plugins to read. + const overallCachePolicy = + overallCachePolicyOverride || + (requestContext.overallCachePolicy = + computeOverallCachePolicy(hints)); + + if ( + overallCachePolicy && + options.calculateHttpHeaders && + response.http + ) { + response.http.headers.set( + 'Cache-Control', + `max-age=${ + overallCachePolicy.maxAge + }, ${overallCachePolicy.scope.toLowerCase()}`, + ); + } - public overrideOverallCachePolicy(overallCachePolicy: Required) { - this.overallCachePolicyOverride = overallCachePolicy; - } + // We should have to explicitly ask to leave the formatted extension in, + // or pass the old-school `cacheControl: true` (as interpreted by + // apollo-server-core/ApolloServer), in order to include the + // engineproxy-aimed extensions. Specifically, we want users of + // apollo-server-plugin-response-cache to be able to specify + // `cacheControl: {defaultMaxAge: 600}` without accidentally turning on + // the extension formatting. + if (options.stripFormattedExtensions !== false) return; - computeOverallCachePolicy(): Required | undefined { - if (this.overallCachePolicyOverride) { - return this.overallCachePolicyOverride; - } + const extensions = + response.extensions || (response.extensions = Object.create(null)); - let lowestMaxAge: number | undefined = undefined; - let scope: CacheScope = CacheScope.Public; + if (typeof extensions.cacheControl !== 'undefined') { + throw new Error("The cacheControl information already existed."); + } - for (const hint of this.hints.values()) { - if (hint.maxAge !== undefined) { - lowestMaxAge = - lowestMaxAge !== undefined - ? Math.min(lowestMaxAge, hint.maxAge) - : hint.maxAge; - } - if (hint.scope === CacheScope.Private) { - scope = CacheScope.Private; + extensions.cacheControl = { + version: 1, + hints: Array.from(hints).map(([path, hint]) => ({ + path: [...responsePathAsArray(path)], + ...hint, + })), + }; } } - - // If maxAge is 0, then we consider it uncacheable so it doesn't matter what - // the scope was. - return lowestMaxAge - ? { - maxAge: lowestMaxAge, - scope, - } - : undefined; } -} +}); function cacheHintFromDirectives( directives: ReadonlyArray | undefined, @@ -255,3 +237,45 @@ function mergeHints( scope: otherHint.scope || hint.scope, }; } + +function computeOverallCachePolicy( + hints: MapResponsePathHints, +): Required | undefined { + let lowestMaxAge: number | undefined = undefined; + let scope: CacheScope = CacheScope.Public; + + for (const hint of hints.values()) { + if (hint.maxAge !== undefined) { + lowestMaxAge = + lowestMaxAge !== undefined + ? Math.min(lowestMaxAge, hint.maxAge) + : hint.maxAge; + } + if (hint.scope === CacheScope.Private) { + scope = CacheScope.Private; + } + } + + // If maxAge is 0, then we consider it uncacheable so it doesn't matter what + // the scope was. + return lowestMaxAge + ? { + maxAge: lowestMaxAge, + scope, + } + : undefined; +} + +function addHint(hints: MapResponsePathHints, path: ResponsePath, hint: CacheHint) { + const existingCacheHint = hints.get(path); + if (existingCacheHint) { + hints.set(path, mergeHints(existingCacheHint, hint)); + } else { + hints.set(path, hint); + } +} + +export const __testing__ = { + addHint, + computeOverallCachePolicy, +}; diff --git a/packages/apollo-cache-control/tsconfig.json b/packages/apollo-cache-control/tsconfig.json index 0de28001c29..29dff935854 100644 --- a/packages/apollo-cache-control/tsconfig.json +++ b/packages/apollo-cache-control/tsconfig.json @@ -7,6 +7,6 @@ "include": ["src/**/*"], "exclude": ["**/__tests__", "**/__mocks__"], "references": [ - { "path": "../graphql-extensions" }, + { "path": "../apollo-server-plugin-base" }, ] } diff --git a/packages/apollo-datasource-rest/package.json b/packages/apollo-datasource-rest/package.json index 227a16649df..2c70ce1a3c1 100644 --- a/packages/apollo-datasource-rest/package.json +++ b/packages/apollo-datasource-rest/package.json @@ -1,6 +1,6 @@ { "name": "apollo-datasource-rest", - "version": "0.9.1-alpha.0", + "version": "0.9.2", "author": "opensource@apollographql.com", "license": "MIT", "repository": { diff --git a/packages/apollo-datasource/package.json b/packages/apollo-datasource/package.json index 5dc85e7bfff..96c6ceda088 100644 --- a/packages/apollo-datasource/package.json +++ b/packages/apollo-datasource/package.json @@ -1,6 +1,6 @@ { "name": "apollo-datasource", - "version": "0.7.1-alpha.0", + "version": "0.7.1", "author": "opensource@apollographql.com", "license": "MIT", "repository": { diff --git a/packages/apollo-engine-reporting-protobuf/package-lock.json b/packages/apollo-engine-reporting-protobuf/package-lock.json index cdeebe199a6..96f69fcc989 100644 --- a/packages/apollo-engine-reporting-protobuf/package-lock.json +++ b/packages/apollo-engine-reporting-protobuf/package-lock.json @@ -1,6 +1,6 @@ { "name": "apollo-engine-reporting-protobuf", - "version": "0.5.1-alpha.0", + "version": "0.5.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/packages/apollo-engine-reporting-protobuf/package.json b/packages/apollo-engine-reporting-protobuf/package.json index 3812458bbb2..c022754150f 100644 --- a/packages/apollo-engine-reporting-protobuf/package.json +++ b/packages/apollo-engine-reporting-protobuf/package.json @@ -1,6 +1,6 @@ { "name": "apollo-engine-reporting-protobuf", - "version": "0.5.1-alpha.0", + "version": "0.5.1", "description": "Protobuf format for Apollo Engine", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-engine-reporting-protobuf/src/reports.proto b/packages/apollo-engine-reporting-protobuf/src/reports.proto index 9f106f1c50b..0c6bc319d78 100644 --- a/packages/apollo-engine-reporting-protobuf/src/reports.proto +++ b/packages/apollo-engine-reporting-protobuf/src/reports.proto @@ -269,8 +269,10 @@ message ReportHeader { string uname = 9; // eg "current", "prod" string schema_tag = 10; - // The hex representation of the sha512 of the introspection response - string schema_hash = 11; + // An id that is used to represent the schema to Apollo Graph Manager + // Using this in place of what used to be schema_hash, since that is no longer + // attached to a schema in the backend. + string executable_schema_id = 11; reserved 3; // removed string service = 3; } diff --git a/packages/apollo-engine-reporting/package.json b/packages/apollo-engine-reporting/package.json index 90cc7285aca..5bdc6047d34 100644 --- a/packages/apollo-engine-reporting/package.json +++ b/packages/apollo-engine-reporting/package.json @@ -1,6 +1,6 @@ { "name": "apollo-engine-reporting", - "version": "1.8.1-alpha.0", + "version": "2.0.0", "description": "Send reports about your GraphQL services to Apollo Graph Manager (previously known as Apollo Engine)", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -16,9 +16,10 @@ "apollo-server-caching": "file:../apollo-server-caching", "apollo-server-env": "file:../apollo-server-env", "apollo-server-errors": "file:../apollo-server-errors", + "apollo-server-plugin-base": "file:../apollo-server-plugin-base", "apollo-server-types": "file:../apollo-server-types", "async-retry": "^1.2.1", - "graphql-extensions": "file:../graphql-extensions" + "uuid": "^8.0.0" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" diff --git a/packages/apollo-engine-reporting/src/__tests__/agent.test.ts b/packages/apollo-engine-reporting/src/__tests__/agent.test.ts index ff6ac43ab6a..565cc5e50d5 100644 --- a/packages/apollo-engine-reporting/src/__tests__/agent.test.ts +++ b/packages/apollo-engine-reporting/src/__tests__/agent.test.ts @@ -2,7 +2,9 @@ import { signatureCacheKey, handleLegacyOptions, EngineReportingOptions, -} from '../agent'; + computeExecutableSchemaId +} from "../agent"; +import { buildSchema } from "graphql"; describe('signature cache key', () => { it('generates without the operationName', () => { @@ -16,6 +18,59 @@ describe('signature cache key', () => { }); }); +describe('Executable Schema Id', () => { + const unsortedGQLSchemaDocument = ` + directive @example on FIELD + union AccountOrUser = Account | User + type Query { + userOrAccount(name: String, id: String): AccountOrUser + } + + type User { + accounts: [Account!] + email: String + name: String! + } + + type Account { + name: String! + id: ID! + } + `; + + const sortedGQLSchemaDocument = ` + directive @example on FIELD + union AccountOrUser = Account | User + + type Account { + name: String! + id: ID! + } + + type Query { + userOrAccount(id: String, name: String): AccountOrUser + } + + type User { + accounts: [Account!] + email: String + name: String! + } + + `; + it('does not normalize GraphQL schemas', () => { + expect(computeExecutableSchemaId(buildSchema(unsortedGQLSchemaDocument))).not.toEqual( + computeExecutableSchemaId(buildSchema(sortedGQLSchemaDocument)) + ); + }); + it('does not normalize strings', () => { + expect(computeExecutableSchemaId(unsortedGQLSchemaDocument)).not.toEqual( + computeExecutableSchemaId(sortedGQLSchemaDocument) + ); + }); +}); + + describe("test handleLegacyOptions(), which converts the deprecated privateVariable and privateHeaders options to the new options' formats", () => { it('Case 1: privateVariables/privateHeaders == False; same as all', () => { const optionsPrivateFalse: EngineReportingOptions = { diff --git a/packages/apollo-engine-reporting/src/__tests__/extension.test.ts b/packages/apollo-engine-reporting/src/__tests__/plugin.test.ts similarity index 70% rename from packages/apollo-engine-reporting/src/__tests__/extension.test.ts rename to packages/apollo-engine-reporting/src/__tests__/plugin.test.ts index 14b50c73cfd..f56038aa375 100644 --- a/packages/apollo-engine-reporting/src/__tests__/extension.test.ts +++ b/packages/apollo-engine-reporting/src/__tests__/plugin.test.ts @@ -1,22 +1,13 @@ import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; -import { - GraphQLExtensionStack, - enableGraphQLExtensions, -} from 'graphql-extensions'; -import { graphql, GraphQLError } from 'graphql'; +import { graphql, GraphQLError, printSchema } from 'graphql'; import { Request } from 'node-fetch'; -import { - EngineReportingExtension, - makeTraceDetails, - makeHTTPRequestHeaders, -} from '../extension'; +import { makeTraceDetails, makeHTTPRequestHeaders, plugin } from '../plugin'; import { Headers } from 'apollo-server-env'; -import { InMemoryLRUCache } from 'apollo-server-caching'; -import { AddTraceArgs } from '../agent'; +import { AddTraceArgs, computeExecutableSchemaId } from '../agent'; import { Trace } from 'apollo-engine-reporting-protobuf'; +import pluginTestHarness from 'apollo-server-core/dist/utils/pluginTestHarness'; -it('trace construction', async () => { - const typeDefs = ` +const typeDefs = ` type User { id: Int name: String @@ -39,7 +30,7 @@ it('trace construction', async () => { } `; - const query = ` +const query = ` query q { author(id: 5) { name @@ -51,43 +42,194 @@ it('trace construction', async () => { } `; +describe('schema reporting', () => { const schema = makeExecutableSchema({ typeDefs }); addMockFunctionsToSchema({ schema }); - enableGraphQLExtensions(schema); - const traces: Array = []; - async function addTrace(args: AddTraceArgs) { - traces.push(args); - } + const addTrace = jest.fn().mockResolvedValue(undefined); + const startSchemaReporting = jest.fn(); + const executableSchemaIdGenerator = jest.fn(computeExecutableSchemaId); - const reportingExtension = new EngineReportingExtension( - {}, - addTrace, - 'schema-hash', - ); - const stack = new GraphQLExtensionStack([reportingExtension]); - const requestDidEnd = stack.requestDidStart({ - request: new Request('http://localhost:123/foo'), - queryString: query, - requestContext: { - request: { + beforeEach(() => { + addTrace.mockClear(); + startSchemaReporting.mockClear(); + executableSchemaIdGenerator.mockClear(); + }); + + it('starts reporting if enabled', async () => { + const pluginInstance = plugin( + { + experimental_schemaReporting: true, + }, + addTrace, + { + startSchemaReporting, + executableSchemaIdGenerator, + }, + ); + + await pluginTestHarness({ + pluginInstance, + schema, + graphqlRequest: { query, operationName: 'q', extensions: { clientName: 'testing suite', }, + http: new Request('http://localhost:123/foo'), }, - context: {}, - cache: new InMemoryLRUCache(), - }, - context: {}, + executor: async ({ request: { query: source } }) => { + return await graphql({ + schema, + source, + }); + }, + }); + + expect(startSchemaReporting).toBeCalledTimes(1); + expect(startSchemaReporting).toBeCalledWith({ + executableSchema: printSchema(schema), + executableSchemaId: executableSchemaIdGenerator(schema), + }); + }); + + it('uses the override schema', async () => { + const pluginInstance = plugin( + { + experimental_schemaReporting: true, + experimental_overrideReportedSchema: typeDefs, + }, + addTrace, + { + startSchemaReporting, + executableSchemaIdGenerator, + }, + ); + + await pluginTestHarness({ + pluginInstance, + schema, + graphqlRequest: { + query, + operationName: 'q', + extensions: { + clientName: 'testing suite', + }, + http: new Request('http://localhost:123/foo'), + }, + executor: async ({ request: { query: source } }) => { + return await graphql({ + schema, + source, + }); + }, + }); + + const expectedExecutableSchemaId = executableSchemaIdGenerator(typeDefs); + expect(startSchemaReporting).toBeCalledTimes(1); + expect(startSchemaReporting).toBeCalledWith({ + executableSchema: typeDefs, + executableSchemaId: expectedExecutableSchemaId, + }); + + // Get the first argument from the first time this is called. + // Not using called with because that has to be exhaustive and this isn't + // testing trace generation + expect(addTrace).toBeCalledWith( + expect.objectContaining({ + executableSchemaId: expectedExecutableSchemaId, + }), + ); + }); + + it('uses the same executable schema id for metric reporting', async () => { + const pluginInstance = plugin( + { + experimental_schemaReporting: true, + }, + addTrace, + { + startSchemaReporting, + executableSchemaIdGenerator, + }, + ); + + await pluginTestHarness({ + pluginInstance, + schema, + graphqlRequest: { + query, + operationName: 'q', + extensions: { + clientName: 'testing suite', + }, + http: new Request('http://localhost:123/foo'), + }, + executor: async ({ request: { query: source } }) => { + return await graphql({ + schema, + source, + }); + }, + }); + + const expectedExecutableSchemaId = executableSchemaIdGenerator(schema); + expect(startSchemaReporting).toBeCalledTimes(1); + expect(startSchemaReporting).toBeCalledWith({ + executableSchema: printSchema(schema), + executableSchemaId: expectedExecutableSchemaId, + }); + // Get the first argument from the first time this is called. + // Not using called with because that has to be exhaustive and this isn't + // testing trace generation + expect(addTrace.mock.calls[0][0].executableSchemaId).toBe( + expectedExecutableSchemaId, + ); }); - await graphql({ +}); + +it('trace construction', async () => { + const schema = makeExecutableSchema({ typeDefs }); + addMockFunctionsToSchema({ schema }); + + const traces: Array = []; + async function addTrace(args: AddTraceArgs) { + traces.push(args); + } + const startSchemaReporting = jest.fn(); + const executableSchemaIdGenerator = jest.fn(); + + const pluginInstance = plugin( + { + /* no options!*/ + }, + addTrace, + { + startSchemaReporting, + executableSchemaIdGenerator, + }, + ); + + await pluginTestHarness({ + pluginInstance, schema, - source: query, - contextValue: { _extensionStack: stack }, + graphqlRequest: { + query, + operationName: 'q', + extensions: { + clientName: 'testing suite', + }, + http: new Request('http://localhost:123/foo'), + }, + executor: async ({ request: { query: source } }) => { + return await graphql({ + schema, + source, + }); + }, }); - requestDidEnd(); + // XXX actually write some tests }); @@ -276,7 +418,7 @@ describe('variableJson output for sendVariableValues transform: custom function ).toEqual(JSON.stringify(null)); }); - const errorThrowingModifier = (input: { + const errorThrowingModifier = (_input: { variables: Record; }): Record => { throw new GraphQLError('testing error handling'); diff --git a/packages/apollo-engine-reporting/src/__tests__/schemaReporter.test.ts b/packages/apollo-engine-reporting/src/__tests__/schemaReporter.test.ts new file mode 100644 index 00000000000..44b317c7fd0 --- /dev/null +++ b/packages/apollo-engine-reporting/src/__tests__/schemaReporter.test.ts @@ -0,0 +1,173 @@ +import nock from 'nock'; +import { reportServerInfoGql, SchemaReporter } from '../schemaReporter'; + +function mockReporterRequest(url: any, variables?: any) { + if (variables) + return nock(url).post( + '/', + JSON.stringify({ + query: reportServerInfoGql, + operationName: 'ReportServerInfo', + variables, + }), + ); + return nock(url).post('/'); +} + +beforeEach(() => { + if (!nock.isActive()) nock.activate(); +}); + +afterEach(() => { + expect(nock.isDone()).toBeTruthy(); + nock.cleanAll(); + nock.restore(); +}); + +const serverInfo = { + bootId: 'string', + executableSchemaId: 'string', + graphVariant: 'string', +}; + +const url = 'http://localhost:4000'; + +describe('Schema reporter', () => { + it('return correct values if no errors', async () => { + const schemaReporter = new SchemaReporter( + serverInfo, + 'schemaSdl', + 'apiKey', + url, + ); + mockReporterRequest(url).reply(200, { + data: { + me: { + __typename: 'ServiceMutation', + reportServerInfo: { + __typename: 'ReportServerInfoResponse', + inSeconds: 30, + withExecutableSchema: false, + }, + }, + }, + }); + + let { + inSeconds, + withExecutableSchema, + } = await schemaReporter.reportServerInfo(false); + expect(inSeconds).toBe(30); + expect(withExecutableSchema).toBe(false); + + mockReporterRequest(url).reply(200, { + data: { + me: { + __typename: 'ServiceMutation', + reportServerInfo: { + __typename: 'ReportServerInfoResponse', + inSeconds: 60, + withExecutableSchema: true, + }, + }, + }, + }); + ({ + inSeconds, + withExecutableSchema, + } = await schemaReporter.reportServerInfo(false)); + expect(inSeconds).toBe(60); + expect(withExecutableSchema).toBe(true); + }); + + it('throws on 500 response', async () => { + const schemaReporter = new SchemaReporter( + serverInfo, + 'schemaSdl', + 'apiKey', + url, + ); + mockReporterRequest(url).reply(500, { + data: { + me: { + reportServerInfo: { + __typename: 'ReportServerInfoResponse', + inSeconds: 30, + withExecutableSchema: false, + }, + }, + }, + }); + + await expect( + schemaReporter.reportServerInfo(false), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"An unexpected HTTP status code (500) was encountered during schema reporting."`, + ); + }); + + it('throws on 200 malformed response', async () => { + const schemaReporter = new SchemaReporter( + serverInfo, + 'schemaSdl', + 'apiKey', + url, + ); + mockReporterRequest(url).reply(200, { + data: { + me: { + reportServerInfo: { + __typename: 'ReportServerInfoResponse', + }, + }, + }, + }); + + await expect( + schemaReporter.reportServerInfo(false), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unexpected response shape from Apollo Graph Manager when reporting server information for schema reporting. If this continues, please reach out to support@apollographql.com. Received response: {\\"me\\":{\\"reportServerInfo\\":{\\"__typename\\":\\"ReportServerInfoResponse\\"}}}"`, + ); + + mockReporterRequest(url).reply(200, { + data: { + me: { + __typename: 'UserMutation', + }, + }, + }); + await expect( + schemaReporter.reportServerInfo(false), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"This server was configured with an API key for a user. Only a service's API key may be used for schema reporting. Please visit the settings for this graph at https://engine.apollographql.com/ to obtain an API key for a service."`, + ); + }); + + it('sends schema if withExecutableSchema is true.', async () => { + const schemaReporter = new SchemaReporter( + serverInfo, + 'schemaSdl', + 'apiKey', + url, + ); + + const variables = { + info: serverInfo, + executableSchema: 'schemaSdl' + }; + mockReporterRequest(url, variables).reply(200, { + data: { + me: { + __typename: 'ServiceMutation', + reportServerInfo: { + __typename: 'ReportServerInfoResponse', + inSeconds: 30, + withExecutableSchema: false, + }, + }, + }, + }); + + await schemaReporter.reportServerInfo(true); + }); +}); diff --git a/packages/apollo-engine-reporting/src/agent.ts b/packages/apollo-engine-reporting/src/agent.ts index 9e360846e8b..b6b91f830d1 100644 --- a/packages/apollo-engine-reporting/src/agent.ts +++ b/packages/apollo-engine-reporting/src/agent.ts @@ -1,20 +1,29 @@ import os from 'os'; import { gzip } from 'zlib'; -import { DocumentNode, GraphQLError } from 'graphql'; +import { + DocumentNode, + GraphQLError, + GraphQLSchema, + printSchema, +} from 'graphql'; import { ReportHeader, Trace, Report, - TracesAndStats + TracesAndStats, } from 'apollo-engine-reporting-protobuf'; import { fetch, RequestAgent, Response } from 'apollo-server-env'; import retry from 'async-retry'; -import { EngineReportingExtension } from './extension'; +import { plugin } from './plugin'; import { GraphQLRequestContext, Logger } from 'apollo-server-types'; import { InMemoryLRUCache } from 'apollo-server-caching'; import { defaultEngineReportingSignature } from 'apollo-graphql'; +import { ApolloServerPlugin } from 'apollo-server-plugin-base'; +import { reportingLoop, SchemaReporter } from './schemaReporter'; +import { v4 as uuidv4 } from 'uuid'; +import { createHash } from 'crypto'; let warnedOnDeprecatedApiKey = false; @@ -48,10 +57,15 @@ export type GenerateClientInfo = ( ) => ClientInfo; // AS3: Drop support for deprecated `ENGINE_API_KEY`. -export function getEngineApiKey( - {engine, skipWarn = false, logger= console }: - {engine: EngineReportingOptions | boolean | undefined, skipWarn?: boolean, logger?: Logger } - ) { +export function getEngineApiKey({ + engine, + skipWarn = false, + logger = console, +}: { + engine: EngineReportingOptions | boolean | undefined; + skipWarn?: boolean; + logger?: Logger; +}) { if (typeof engine === 'object') { if (engine.apiKey) { return engine.apiKey; @@ -60,34 +74,52 @@ export function getEngineApiKey( const legacyApiKeyFromEnv = process.env.ENGINE_API_KEY; const apiKeyFromEnv = process.env.APOLLO_KEY; - if(legacyApiKeyFromEnv && apiKeyFromEnv && !skipWarn) { - logger.warn("Using `APOLLO_KEY` since `ENGINE_API_KEY` (deprecated) is also set in the environment."); + if (legacyApiKeyFromEnv && apiKeyFromEnv && !skipWarn) { + logger.warn( + 'Using `APOLLO_KEY` since `ENGINE_API_KEY` (deprecated) is also set in the environment.', + ); } - if(legacyApiKeyFromEnv && !warnedOnDeprecatedApiKey && !skipWarn) { - logger.warn("[deprecated] The `ENGINE_API_KEY` environment variable has been renamed to `APOLLO_KEY`."); + if (legacyApiKeyFromEnv && !warnedOnDeprecatedApiKey && !skipWarn) { + logger.warn( + '[deprecated] The `ENGINE_API_KEY` environment variable has been renamed to `APOLLO_KEY`.', + ); warnedOnDeprecatedApiKey = true; } - return apiKeyFromEnv || legacyApiKeyFromEnv || '' + return apiKeyFromEnv || legacyApiKeyFromEnv || ''; } // AS3: Drop support for deprecated `ENGINE_SCHEMA_TAG`. -export function getEngineGraphVariant(engine: EngineReportingOptions | boolean | undefined, logger: Logger = console): string | undefined { +export function getEngineGraphVariant( + engine: EngineReportingOptions | boolean | undefined, + logger: Logger = console, +): string | undefined { if (engine === false) { return; - } else if (typeof engine === 'object' && (engine.graphVariant || engine.schemaTag)) { + } else if ( + typeof engine === 'object' && + (engine.graphVariant || engine.schemaTag) + ) { if (engine.graphVariant && engine.schemaTag) { - throw new Error('Cannot set both engine.graphVariant and engine.schemaTag. Please use engine.graphVariant.'); + throw new Error( + 'Cannot set both engine.graphVariant and engine.schemaTag. Please use engine.graphVariant.', + ); } if (engine.schemaTag) { - logger.warn('[deprecated] The `schemaTag` property within `engine` configuration has been renamed to `graphVariant`.'); + logger.warn( + '[deprecated] The `schemaTag` property within `engine` configuration has been renamed to `graphVariant`.', + ); } return engine.graphVariant || engine.schemaTag; } else { if (process.env.ENGINE_SCHEMA_TAG) { - logger.warn('[deprecated] The `ENGINE_SCHEMA_TAG` environment variable has been renamed to `APOLLO_GRAPH_VARIANT`.'); + logger.warn( + '[deprecated] The `ENGINE_SCHEMA_TAG` environment variable has been renamed to `APOLLO_GRAPH_VARIANT`.', + ); } if (process.env.ENGINE_SCHEMA_TAG && process.env.APOLLO_GRAPH_VARIANT) { - throw new Error('`APOLLO_GRAPH_VARIANT` and `ENGINE_SCHEMA_TAG` (deprecated) environment variables must not both be set.') + throw new Error( + '`APOLLO_GRAPH_VARIANT` and `ENGINE_SCHEMA_TAG` (deprecated) environment variables must not both be set.', + ); } return process.env.APOLLO_GRAPH_VARIANT || process.env.ENGINE_SCHEMA_TAG; } @@ -119,9 +151,15 @@ export interface EngineReportingOptions { */ maxUncompressedReportSize?: number; /** + * [DEPRECATED] this option was replaced by tracesEndpointUrl * The URL of the Engine report ingress server. */ endpointUrl?: string; + /** + * The URL to the Apollo Graph Manager ingress endpoint. + * (Previously, this was `endpointUrl`, which will be removed in AS3). + */ + tracesEndpointUrl?: string; /** * If set, prints all reports as JSON when they are sent. */ @@ -238,6 +276,47 @@ export interface EngineReportingOptions { */ generateClientInfo?: GenerateClientInfo; + /** + * **(Experimental)** Enable schema reporting from this server with + * Apollo Graph Manager. + * + * The use of this option avoids the need to rgister schemas manually within + * CI deployment pipelines using `apollo schema:push` by periodically + * reporting this server's schema (when changes are detected) along with + * additional details about its runtime environment to Apollo Graph Manager. + * + * See [our _preview + * documentation_](https://github.com/apollographql/apollo-schema-reporting-preview-docs) + * for more information. + */ + experimental_schemaReporting?: boolean; + + /** + * Override the reported schema that is reported to AGM. + * This schema does not go through any normalizations and the string is directly sent to Apollo Graph Manager. + * This would be useful for comments or other ordering and whitespace changes that get stripped when generating a `GraphQLSchema` + */ + experimental_overrideReportedSchema?: string; + + /** + * The schema reporter waits before starting reporting. + * By default, the report waits some random amount of time between 0 and 10 seconds. + * A longer interval leads to more staggered starts which means it is less likely + * multiple servers will get asked to upload the same schema. + * + * If this server runs in lambda or in other constrained environments it would be useful + * to decrease the schema reporting max wait time to be less than default. + * + * This number will be the max for the range in ms that the schema reporter will + * wait before starting to report. + */ + experimental_schemaReportingInitialDelayMaxMs?: number; + + /** + * The URL to use for reporting schemas. + */ + schemaReportingUrl?: string; + /** * A logger interface to be used for output and errors. When not provided * it will default to the server's own `logger` implementation and use @@ -250,9 +329,10 @@ export interface AddTraceArgs { trace: Trace; operationName: string; queryHash: string; - schemaHash: string; - queryString?: string; - documentAST?: DocumentNode; + executableSchemaId: string; + source?: string; + document?: DocumentNode; + logger: Logger, } const serviceHeaderDefaults = { @@ -263,33 +343,64 @@ const serviceHeaderDefaults = { uname: `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`, }; +class ReportData { + report!: Report; + size!: number; + readonly header: ReportHeader; + constructor(executableSchemaId: string, graphVariant: string) { + this.header = new ReportHeader({ + ...serviceHeaderDefaults, + executableSchemaId, + schemaTag: graphVariant, + }); + this.reset(); + } + reset() { + this.report = new Report({ header: this.header }); + this.size = 0; + } +} + // EngineReportingAgent is a persistent object which creates // EngineReportingExtensions for each request and sends batches of trace reports // to the Engine server. export class EngineReportingAgent { private readonly options: EngineReportingOptions; private readonly apiKey: string; - private logger: Logger = console; - private graphVariant: string; - private reports: { [schemaHash: string]: Report } = Object.create( - null, - ); - private reportSizes: { [schemaHash: string]: number } = Object.create(null); + private readonly logger: Logger = console; + private readonly graphVariant: string; + + private readonly reportDataByExecutableSchemaId: { + [executableSchemaId: string]: ReportData | undefined; + } = Object.create(null); + private reportTimer: any; // timer typing is weird and node-specific private readonly sendReportsImmediately?: boolean; private stopped: boolean = false; - private reportHeaders: { [schemaHash: string]: ReportHeader } = Object.create( - null, - ); private signatureCache: InMemoryLRUCache; private signalHandlers = new Map(); + private currentSchemaReporter?: SchemaReporter; + private readonly bootId: string; + private lastSeenExecutableSchemaToId?: { + executableSchema: string | GraphQLSchema; + executableSchemaId: string; + }; + + private readonly tracesEndpointUrl: string; + public constructor(options: EngineReportingOptions = {}) { this.options = options; - this.apiKey = getEngineApiKey({engine: this.options, skipWarn: false, logger: this.logger}); + this.apiKey = getEngineApiKey({ + engine: this.options, + skipWarn: false, + logger: this.logger, + }); if (options.logger) this.logger = options.logger; + this.bootId = uuidv4(); this.graphVariant = getEngineGraphVariant(options, this.logger) || ''; + if (!this.apiKey) { throw new Error( `To use EngineReportingAgent, you must specify an API key via the apiKey option or the APOLLO_KEY environment variable.`, @@ -311,7 +422,7 @@ export class EngineReportingAgent { if (this.options.handleSignals !== false) { const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; - signals.forEach(signal => { + signals.forEach((signal) => { // Note: Node only started sending signal names to signal events with // Node v10 so we can't use that feature here. const handler: NodeJS.SignalsListener = async () => { @@ -324,41 +435,78 @@ export class EngineReportingAgent { }); } + if (this.options.endpointUrl) { + this.logger.warn( + '[deprecated] The `endpointUrl` option within `engine` has been renamed to `tracesEndpointUrl`.', + ); + } + this.tracesEndpointUrl = + (this.options.endpointUrl || + this.options.tracesEndpointUrl || + 'https://engine-report.apollodata.com') + '/api/ingress/traces'; + // Handle the legacy options: privateVariables and privateHeaders handleLegacyOptions(this.options); } - public newExtension(schemaHash: string): EngineReportingExtension { - return new EngineReportingExtension( - this.options, - this.addTrace.bind(this), - schemaHash, - ); + private executableSchemaIdGenerator(schema: string | GraphQLSchema) { + if (this.lastSeenExecutableSchemaToId?.executableSchema === schema) { + return this.lastSeenExecutableSchemaToId.executableSchemaId; + } + const id = computeExecutableSchemaId(schema); + + // We override this variable every time we get a new schema so we cache + // the last seen value. It mostly a cached pair. + this.lastSeenExecutableSchemaToId = { + executableSchema: schema, + executableSchemaId: id, + }; + + return id; + } + + public newPlugin(): ApolloServerPlugin { + return plugin(this.options, this.addTrace.bind(this), { + startSchemaReporting: this.startSchemaReporting.bind(this), + executableSchemaIdGenerator: this.executableSchemaIdGenerator.bind(this), + }); + } + + private getReportData(executableSchemaId: string): ReportData { + const existing = this.reportDataByExecutableSchemaId[executableSchemaId]; + if (existing) { + return existing; + } + const reportData = new ReportData(executableSchemaId, this.graphVariant); + this.reportDataByExecutableSchemaId[executableSchemaId] = reportData; + return reportData; } public async addTrace({ trace, queryHash, - documentAST, + document, operationName, - queryString, - schemaHash, + source, + executableSchemaId, + /** + * Since this agent instruments the plugin with its `options.logger`, but + * also passes off a reference to this `addTrace` method which is invoked + * with the availability of a per-request `logger`, this `logger` (in this + * destructuring) is already conditionally either: + * + * 1. The `logger` that was passed into the `options` for the agent. + * 2. The request-specific `logger`. + */ + logger, }: AddTraceArgs): Promise { // Ignore traces that come in after stop(). if (this.stopped) { return; } - if (!(schemaHash in this.reports)) { - this.reportHeaders[schemaHash] = new ReportHeader({ - ...serviceHeaderDefaults, - schemaHash, - schemaTag: this.graphVariant, - }); - // initializes this.reports[reportHash] - this.resetReport(schemaHash); - } - const report = this.reports[schemaHash]; + const reportData = this.getReportData(executableSchemaId); + const { report } = reportData; const protobufError = Trace.verify(trace); if (protobufError) { @@ -368,9 +516,10 @@ export class EngineReportingAgent { const signature = await this.getTraceSignature({ queryHash, - documentAST, - queryString, + document, + source, operationName, + logger, }); const statsReportKey = `# ${operationName || '-'}\n${signature}`; @@ -383,28 +532,30 @@ export class EngineReportingAgent { (report.tracesPerQuery[statsReportKey] as any).encodedTraces.push( encodedTrace, ); - this.reportSizes[schemaHash] += - encodedTrace.length + Buffer.byteLength(statsReportKey); + reportData.size += encodedTrace.length + Buffer.byteLength(statsReportKey); // If the buffer gets big (according to our estimate), send. if ( this.sendReportsImmediately || - this.reportSizes[schemaHash] >= + reportData.size >= (this.options.maxUncompressedReportSize || 4 * 1024 * 1024) ) { - await this.sendReportAndReportErrors(schemaHash); + await this.sendReportAndReportErrors(executableSchemaId); } } public async sendAllReports(): Promise { await Promise.all( - Object.keys(this.reports).map(hash => this.sendReport(hash)), + Object.keys(this.reportDataByExecutableSchemaId).map((id) => + this.sendReport(id), + ), ); } - public async sendReport(schemaHash: string): Promise { - const report = this.reports[schemaHash]; - this.resetReport(schemaHash); + public async sendReport(executableSchemaId: string): Promise { + const reportData = this.getReportData(executableSchemaId); + const { report } = reportData; + reportData.reset(); if (Object.keys(report.tracesPerQuery).length === 0) { return; @@ -452,16 +603,12 @@ export class EngineReportingAgent { }); }); - const endpointUrl = - (this.options.endpointUrl || 'https://engine-report.apollodata.com') + - '/api/ingress/traces'; - // Wrap fetch with async-retry for automatic retrying const response: Response = await retry( // Retry on network errors and 5xx HTTP // responses. async () => { - const curResponse = await fetch(endpointUrl, { + const curResponse = await fetch(this.tracesEndpointUrl, { method: 'POST', headers: { 'user-agent': 'apollo-engine-reporting', @@ -474,8 +621,9 @@ export class EngineReportingAgent { if (curResponse.status >= 500 && curResponse.status < 600) { throw new Error( - `HTTP status ${curResponse.status}, ${(await curResponse.text()) || - '(no body)'}`, + `HTTP status ${curResponse.status}, ${ + (await curResponse.text()) || '(no body)' + }`, ); } else { return curResponse; @@ -514,6 +662,59 @@ export class EngineReportingAgent { } } + public startSchemaReporting({ + executableSchemaId, + executableSchema, + }: { + executableSchemaId: string; + executableSchema: string; + }) { + if (this.currentSchemaReporter) { + this.currentSchemaReporter.stop(); + } + + const serverInfo = { + bootId: this.bootId, + graphVariant: this.graphVariant, + // The infra environment in which this edge server is running, e.g. localhost, Kubernetes + // Length must be <= 256 characters. + platform: process.env.APOLLO_SERVER_PLATFORM || 'local', + runtimeVersion: `node ${process.version}`, + executableSchemaId: executableSchemaId, + // An identifier used to distinguish the version of the server code such as git or docker sha. + // Length must be <= 256 charecters + userVersion: process.env.APOLLO_SERVER_USER_VERSION, + // "An identifier for the server instance. Length must be <= 256 characters. + serverId: + process.env.APOLLO_SERVER_ID || process.env.HOSTNAME || os.hostname(), + libraryVersion: `apollo-engine-reporting@${ + require('../package.json').version + }`, + }; + + // Jitter the startup between 0 and 10 seconds + const delay = Math.floor( + Math.random() * + (this.options.experimental_schemaReportingInitialDelayMaxMs || 10_000), + ); + + const schemaReporter = new SchemaReporter( + serverInfo, + executableSchema, + this.apiKey, + this.options.schemaReportingUrl, + ); + + const fallbackReportingDelayInMs = 20_000; + + this.currentSchemaReporter = schemaReporter; + const logger = this.logger; + + setTimeout(function () { + reportingLoop(schemaReporter, logger, false, fallbackReportingDelayInMs); + }, delay); + } + // Stop prevents reports from being sent automatically due to time or buffer // size, and stop buffering new traces. You may still manually send a last // report by calling sendReport(). @@ -528,23 +729,29 @@ export class EngineReportingAgent { this.reportTimer = undefined; } + if (this.currentSchemaReporter) { + this.currentSchemaReporter.stop(); + } + this.stopped = true; } private async getTraceSignature({ queryHash, operationName, - documentAST, - queryString, + document, + source, + logger, }: { queryHash: string; operationName: string; - documentAST?: DocumentNode; - queryString?: string; + document?: DocumentNode; + source?: string; + logger: Logger; }): Promise { - if (!documentAST && !queryString) { + if (!document && !source) { // This shouldn't happen: one of those options must be passed to runQuery. - throw new Error('No queryString or documentAST?'); + throw new Error('No document or source?'); } const cacheKey = signatureCacheKey(queryHash, operationName); @@ -559,7 +766,7 @@ export class EngineReportingAgent { return cachedSignature; } - if (!documentAST) { + if (!document) { // We didn't get an AST, possibly because of a parse failure. Let's just // use the full query string. // @@ -567,29 +774,48 @@ export class EngineReportingAgent { // hides literals, you might end up sending literals for queries // that fail parsing or validation. Provide some way to mask them // anyway? - return queryString as string; + return source as string; } const generatedSignature = ( this.options.calculateSignature || defaultEngineReportingSignature - )(documentAST, operationName); + )(document, operationName); // Intentionally not awaited so the cache can be written to at leisure. - this.signatureCache.set(cacheKey, generatedSignature); + // + // As of the writing of this comment, this signature cache is exclusively + // backed by an `InMemoryLRUCache` which cannot do anything + // non-synchronously, though that will probably change in the future, + // and a distributed cache store, like Redis, doesn't seem unfathomable. + // + // Due in part to the plugin being separate from the `EngineReportingAgent`, + // the loggers are difficult to track down here. Errors will be logged to + // either the request-specific logger on the request context (if available) + // or to the `logger` that was passed into `EngineReportingOptions` which + // is provided in the `EngineReportingAgent` constructor options. + this.signatureCache.set(cacheKey, generatedSignature) + .catch(err => { + logger.warn( + 'Could not store signature cache. ' + + (err && err.message) || err + ) + }); return generatedSignature; } private async sendAllReportsAndReportErrors(): Promise { await Promise.all( - Object.keys(this.reports).map(schemaHash => - this.sendReportAndReportErrors(schemaHash), + Object.keys( + this.reportDataByExecutableSchemaId, + ).map((executableSchemaId) => + this.sendReportAndReportErrors(executableSchemaId), ), ); } - private sendReportAndReportErrors(schemaHash: string): Promise { - return this.sendReport(schemaHash).catch(err => { + private sendReportAndReportErrors(executableSchemaId: string): Promise { + return this.sendReport(executableSchemaId).catch((err) => { // This catch block is primarily intended to catch network errors from // the retried request itself, which include network errors and non-2xx // HTTP errors. @@ -600,13 +826,6 @@ export class EngineReportingAgent { } }); } - - private resetReport(schemaHash: string) { - this.reports[schemaHash] = new Report({ - header: this.reportHeaders[schemaHash], - }); - this.reportSizes[schemaHash] = 0; - } } function createSignatureCache({ @@ -716,3 +935,13 @@ function makeSendValuesBaseOptionsFromLegacy( ? { none: true } : { all: true }; } + +export function computeExecutableSchemaId( + schema: string | GraphQLSchema, +): string { + // Can't call digest on this object twice. Creating new object each function call + const sha256 = createHash('sha256'); + const schemaDocument = + typeof schema === 'string' ? schema : printSchema(schema); + return sha256.update(schemaDocument).digest('hex'); +} diff --git a/packages/apollo-engine-reporting/src/extension.ts b/packages/apollo-engine-reporting/src/extension.ts deleted file mode 100644 index 6c90f6bf384..00000000000 --- a/packages/apollo-engine-reporting/src/extension.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { GraphQLRequestContext, WithRequired, Logger } from 'apollo-server-types'; -import { Request, Headers } from 'apollo-server-env'; -import { - GraphQLResolveInfo, - DocumentNode, - ExecutionArgs, - GraphQLError, -} from 'graphql'; -import { GraphQLExtension, EndHandler } from 'graphql-extensions'; -import { Trace } from 'apollo-engine-reporting-protobuf'; - -import { - EngineReportingOptions, - GenerateClientInfo, - AddTraceArgs, - VariableValueOptions, - SendValuesBaseOptions, -} from './agent'; -import { EngineReportingTreeBuilder } from './treeBuilder'; - -const clientNameHeaderKey = 'apollographql-client-name'; -const clientReferenceIdHeaderKey = 'apollographql-client-reference-id'; -const clientVersionHeaderKey = 'apollographql-client-version'; - -// EngineReportingExtension is the per-request GraphQLExtension which creates a -// trace (in protobuf Trace format) for a single request. When the request is -// done, it passes the Trace back to its associated EngineReportingAgent via the -// addTrace callback in its constructor. This class isn't for direct use; its -// constructor is a private API for communicating with EngineReportingAgent. -// Its public methods all implement the GraphQLExtension interface. -export class EngineReportingExtension - implements GraphQLExtension { - private logger: Logger = console; - private treeBuilder: EngineReportingTreeBuilder; - private explicitOperationName?: string | null; - private queryString?: string; - private documentAST?: DocumentNode; - private options: EngineReportingOptions; - private addTrace: (args: AddTraceArgs) => Promise; - private generateClientInfo: GenerateClientInfo; - - public constructor( - options: EngineReportingOptions, - addTrace: (args: AddTraceArgs) => Promise, - private schemaHash: string, - ) { - this.options = { - ...options, - }; - if (options.logger) this.logger = options.logger; - this.addTrace = addTrace; - this.generateClientInfo = - options.generateClientInfo || defaultGenerateClientInfo; - - this.treeBuilder = new EngineReportingTreeBuilder({ - rewriteError: options.rewriteError, - logger: this.logger, - }); - } - - public requestDidStart(o: { - request: Request; - queryString?: string; - parsedQuery?: DocumentNode; - variables?: Record; - context: TContext; - extensions?: Record; - requestContext: WithRequired< - GraphQLRequestContext, - 'metrics' | 'queryHash' - >; - }): EndHandler { - this.treeBuilder.startTiming(); - o.requestContext.metrics.startHrTime = this.treeBuilder.startHrTime; - - // Generally, we'll get queryString here and not parsedQuery; we only get - // parsedQuery if you're using an OperationStore. In normal cases we'll get - // our documentAST in the execution callback after it is parsed. - const queryHash = o.requestContext.queryHash; - this.queryString = o.queryString; - this.documentAST = o.parsedQuery; - - this.treeBuilder.trace.http = new Trace.HTTP({ - method: - Trace.HTTP.Method[o.request.method as keyof typeof Trace.HTTP.Method] || - Trace.HTTP.Method.UNKNOWN, - // Host and path are not used anywhere on the backend, so let's not bother - // trying to parse request.url to get them, which is a potential - // source of bugs because integrations have different behavior here. - // On Node's HTTP module, request.url only includes the path - // (see https://nodejs.org/api/http.html#http_message_url) - // The same is true on Lambda (where we pass event.path) - // But on environments like Cloudflare we do get a complete URL. - host: null, - path: null, - }); - - if (this.options.sendHeaders) { - makeHTTPRequestHeaders( - this.treeBuilder.trace.http, - o.request.headers, - this.options.sendHeaders, - ); - } - - if (o.requestContext.metrics.persistedQueryHit) { - this.treeBuilder.trace.persistedQueryHit = true; - } - if (o.requestContext.metrics.persistedQueryRegister) { - this.treeBuilder.trace.persistedQueryRegister = true; - } - - if (o.variables) { - this.treeBuilder.trace.details = makeTraceDetails( - o.variables, - this.options.sendVariableValues, - o.queryString, - ); - } - - const clientInfo = this.generateClientInfo(o.requestContext); - if (clientInfo) { - // While clientAddress could be a part of the protobuf, we'll ignore it for - // now, since the backend does not group by it and Engine frontend will not - // support it in the short term - const { clientName, clientVersion, clientReferenceId } = clientInfo; - // the backend makes the choice of mapping clientName => clientReferenceId if - // no custom reference id is provided - this.treeBuilder.trace.clientVersion = clientVersion || ''; - this.treeBuilder.trace.clientReferenceId = clientReferenceId || ''; - this.treeBuilder.trace.clientName = clientName || ''; - } - - return () => { - this.treeBuilder.stopTiming(); - - this.treeBuilder.trace.fullQueryCacheHit = !!o.requestContext.metrics - .responseCacheHit; - this.treeBuilder.trace.forbiddenOperation = !!o.requestContext.metrics - .forbiddenOperation; - this.treeBuilder.trace.registeredOperation = !!o.requestContext.metrics - .registeredOperation; - - // If the user did not explicitly specify an operation name (which we - // would have saved in `executionDidStart`), but the request pipeline made - // it far enough to figure out what the operation name must be and store - // it on requestContext.operationName, use that name. (Note that this - // depends on the assumption that the RequestContext passed to - // requestDidStart, which does not yet have operationName, will be mutated - // to add operationName later.) - const operationName = - this.explicitOperationName || o.requestContext.operationName || ''; - const documentAST = this.documentAST || o.requestContext.document; - - // If this was a federated operation and we're the gateway, add the query plan - // to the trace. - if (o.requestContext.metrics.queryPlanTrace) { - this.treeBuilder.trace.queryPlan = - o.requestContext.metrics.queryPlanTrace; - } - - this.addTrace({ - operationName, - queryHash, - documentAST, - queryString: this.queryString || '', - trace: this.treeBuilder.trace, - schemaHash: this.schemaHash, - }); - }; - } - - public executionDidStart(o: { executionArgs: ExecutionArgs }) { - // If the operationName is explicitly provided, save it. Note: this is the - // operationName provided by the user. It might be empty if they're relying on - // the "just use the only operation I sent" behavior, even if that operation - // has a name. - // - // It's possible that execution is about to fail because this operation - // isn't actually in the document. We want to know the name in that case - // too, which is why it's important that we save the name now, and not just - // rely on requestContext.operationName (which will be null in this case). - if (o.executionArgs.operationName) { - this.explicitOperationName = o.executionArgs.operationName; - } - this.documentAST = o.executionArgs.document; - } - - public willResolveField( - _source: any, - _args: { [argName: string]: any }, - _context: TContext, - info: GraphQLResolveInfo, - ): ((error: Error | null, result: any) => void) | void { - return this.treeBuilder.willResolveField(info); - // We could save the error into the trace during the end handler, but it - // won't have all the information that graphql-js adds to it later, like - // 'locations'. - } - - public didEncounterErrors(errors: GraphQLError[]) { - this.treeBuilder.didEncounterErrors(errors); - } -} - -// Helpers for producing traces. - -function defaultGenerateClientInfo({ request }: GraphQLRequestContext) { - // Default to using the `apollo-client-x` header fields if present. - // If none are present, fallback on the `clientInfo` query extension - // for backwards compatibility. - // The default value if neither header values nor query extension is - // set is the empty String for all fields (as per protobuf defaults) - if ( - request.http && - request.http.headers && - (request.http.headers.get(clientNameHeaderKey) || - request.http.headers.get(clientVersionHeaderKey) || - request.http.headers.get(clientReferenceIdHeaderKey)) - ) { - return { - clientName: request.http.headers.get(clientNameHeaderKey), - clientVersion: request.http.headers.get(clientVersionHeaderKey), - clientReferenceId: request.http.headers.get(clientReferenceIdHeaderKey), - }; - } else if (request.extensions && request.extensions.clientInfo) { - return request.extensions.clientInfo; - } else { - return {}; - } -} - -// Creates trace details from request variables, given a specification for modifying -// values of private or sensitive variables. -// The details will include all variable names and their (possibly hidden or modified) values. -// If sendVariableValues is {all: bool}, {none: bool} or {exceptNames: Array}, the option will act similarly to -// to the to-be-deprecated options.privateVariables, except that the redacted variable -// names will still be visible in the UI even if the values are hidden. -// If sendVariableValues is null or undefined, we default to the {none: true} case. -export function makeTraceDetails( - variables: Record, - sendVariableValues?: VariableValueOptions, - operationString?: string, -): Trace.Details { - const details = new Trace.Details(); - const variablesToRecord = (() => { - if (sendVariableValues && 'transform' in sendVariableValues) { - const originalKeys = Object.keys(variables); - try { - // Custom function to allow user to specify what variablesJson will look like - const modifiedVariables = sendVariableValues.transform({ - variables: variables, - operationString: operationString, - }); - return cleanModifiedVariables(originalKeys, modifiedVariables); - } catch (e) { - // If the custom function provided by the user throws an exception, - // change all the variable values to an appropriate error message. - return handleVariableValueTransformError(originalKeys); - } - } else { - return variables; - } - })(); - - // Note: we explicitly do *not* include the details.rawQuery field. The - // Engine web app currently does nothing with this other than store it in - // the database and offer it up via its GraphQL API, and sending it means - // that using calculateSignature to hide sensitive data in the query - // string is ineffective. - Object.keys(variablesToRecord).forEach(name => { - if ( - !sendVariableValues || - ('none' in sendVariableValues && sendVariableValues.none) || - ('all' in sendVariableValues && !sendVariableValues.all) || - ('exceptNames' in sendVariableValues && - // We assume that most users will have only a few variables values to hide, - // or will just set {none: true}; we can change this - // linear-time operation if it causes real performance issues. - sendVariableValues.exceptNames.includes(name)) || - ('onlyNames' in sendVariableValues && - !sendVariableValues.onlyNames.includes(name)) - ) { - // Special case for private variables. Note that this is a different - // representation from a variable containing the empty string, as that - // will be sent as '""'. - details.variablesJson![name] = ''; - } else { - try { - details.variablesJson![name] = - typeof variablesToRecord[name] === 'undefined' - ? '' - : JSON.stringify(variablesToRecord[name]); - } catch (e) { - details.variablesJson![name] = JSON.stringify( - '[Unable to convert value to JSON]', - ); - } - } - }); - return details; -} - -function handleVariableValueTransformError( - variableNames: string[], -): Record { - const modifiedVariables = Object.create(null); - variableNames.forEach(name => { - modifiedVariables[name] = '[PREDICATE_FUNCTION_ERROR]'; - }); - return modifiedVariables; -} - -// Helper for makeTraceDetails() to enforce that the keys of a modified 'variables' -// matches that of the original 'variables' -function cleanModifiedVariables( - originalKeys: Array, - modifiedVariables: Record, -): Record { - const cleanedVariables: Record = Object.create(null); - originalKeys.forEach(name => { - cleanedVariables[name] = modifiedVariables[name]; - }); - return cleanedVariables; -} - -export function makeHTTPRequestHeaders( - http: Trace.IHTTP, - headers: Headers, - sendHeaders?: SendValuesBaseOptions, -): void { - if ( - !sendHeaders || - ('none' in sendHeaders && sendHeaders.none) || - ('all' in sendHeaders && !sendHeaders.all) - ) { - return; - } - for (const [key, value] of headers) { - const lowerCaseKey = key.toLowerCase(); - if ( - ('exceptNames' in sendHeaders && - // We assume that most users only have a few headers to hide, or will - // just set {none: true} ; we can change this linear-time - // operation if it causes real performance issues. - sendHeaders.exceptNames.some(exceptHeader => { - // Headers are case-insensitive, and should be compared as such. - return exceptHeader.toLowerCase() === lowerCaseKey; - })) || - ('onlyNames' in sendHeaders && - !sendHeaders.onlyNames.some(header => { - return header.toLowerCase() === lowerCaseKey; - })) - ) { - continue; - } - - switch (key) { - case 'authorization': - case 'cookie': - case 'set-cookie': - break; - default: - http!.requestHeaders![key] = new Trace.HTTP.Values({ - value: [value], - }); - } - } -} diff --git a/packages/apollo-engine-reporting/src/federatedExtension.ts b/packages/apollo-engine-reporting/src/federatedExtension.ts deleted file mode 100644 index 711c95fbf6e..00000000000 --- a/packages/apollo-engine-reporting/src/federatedExtension.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { GraphQLResolveInfo, GraphQLError } from 'graphql'; -import { GraphQLExtension } from 'graphql-extensions'; -import { Trace } from 'apollo-engine-reporting-protobuf'; -import { GraphQLRequestContext } from 'apollo-server-types'; - -import { EngineReportingTreeBuilder } from './treeBuilder'; - -export class EngineFederatedTracingExtension - implements GraphQLExtension { - private enabled = false; - private done = false; - private treeBuilder: EngineReportingTreeBuilder; - - public constructor(options: { - rewriteError?: (err: GraphQLError) => GraphQLError | null; - }) { - this.treeBuilder = new EngineReportingTreeBuilder({ - rewriteError: options.rewriteError, - }); - } - - public requestDidStart(o: { - requestContext: GraphQLRequestContext; - }) { - // XXX Provide a mechanism to customize this logic. - const http = o.requestContext.request.http; - if ( - http && - http.headers.get('apollo-federation-include-trace') === 'ftv1' - ) { - this.enabled = true; - } - - if (this.enabled) { - this.treeBuilder.startTiming(); - } - } - - public willResolveField( - _source: any, - _args: { [argName: string]: any }, - _context: TContext, - info: GraphQLResolveInfo, - ): ((error: Error | null, result: any) => void) | void { - if (this.enabled) { - return this.treeBuilder.willResolveField(info); - } - } - - public didEncounterErrors(errors: GraphQLError[]) { - if (this.enabled) { - this.treeBuilder.didEncounterErrors(errors); - } - } - - // The ftv1 extension is a base64'd Trace protobuf containing only the - // durationNs, startTime, endTime, and root fields. - // - // Note: format() is only called after executing an operation, and - // specifically isn't called for parse or validation errors. Parse and validation - // errors in a federated backend will get reported to the end user as a downstream - // error but will not get reported to Engine (because Engine filters out downstream - // errors)! See #3091. - public format(): [string, string] | undefined { - if (!this.enabled) { - return; - } - if (this.done) { - throw Error('format called twice?'); - } - - // We record the end time at the latest possible time: right before serializing the trace. - // If we wait any longer, the time we record won't actually be sent anywhere! - this.treeBuilder.stopTiming(); - this.done = true; - - const encodedUint8Array = Trace.encode(this.treeBuilder.trace).finish(); - const encodedBuffer = Buffer.from( - encodedUint8Array, - encodedUint8Array.byteOffset, - encodedUint8Array.byteLength, - ); - return ['ftv1', encodedBuffer.toString('base64')]; - } -} diff --git a/packages/apollo-engine-reporting/src/federatedPlugin.ts b/packages/apollo-engine-reporting/src/federatedPlugin.ts new file mode 100644 index 00000000000..ce2a5580668 --- /dev/null +++ b/packages/apollo-engine-reporting/src/federatedPlugin.ts @@ -0,0 +1,69 @@ +import { Trace } from 'apollo-engine-reporting-protobuf'; +import { EngineReportingTreeBuilder } from './treeBuilder'; +import { ApolloServerPlugin } from "apollo-server-plugin-base"; +import { EngineReportingOptions } from "./agent"; + +type FederatedReportingOptions = Pick, 'rewriteError'> + +// This ftv1 plugin produces a base64'd Trace protobuf containing only the +// durationNs, startTime, endTime, and root fields. This output is placed +// on the `extensions`.`ftv1` property of the response. The Apollo Gateway +// utilizes this data to construct the full trace and submit it to Apollo +// Graph Manager ingress. +const federatedPlugin = ( + options: FederatedReportingOptions = Object.create(null), +): ApolloServerPlugin => { + return { + requestDidStart({ request: { http } }) { + const treeBuilder: EngineReportingTreeBuilder = + new EngineReportingTreeBuilder({ + rewriteError: options.rewriteError, + }); + + // XXX Provide a mechanism to customize this logic. + if (http?.headers.get('apollo-federation-include-trace') !== 'ftv1') { + return; + } + + treeBuilder.startTiming(); + + return { + executionDidStart: () => ({ + willResolveField({ info }) { + return treeBuilder.willResolveField(info); + }, + }), + + didEncounterErrors({ errors }) { + treeBuilder.didEncounterErrors(errors); + }, + + willSendResponse({ response }) { + // We record the end time at the latest possible time: right before serializing the trace. + // If we wait any longer, the time we record won't actually be sent anywhere! + treeBuilder.stopTiming(); + + const encodedUint8Array = Trace.encode(treeBuilder.trace).finish(); + const encodedBuffer = Buffer.from( + encodedUint8Array, + encodedUint8Array.byteOffset, + encodedUint8Array.byteLength, + ); + + const extensions = + response.extensions || (response.extensions = Object.create(null)); + + // This should only happen if another plugin is using the same name- + // space within the `extensions` object and got to it before us. + if (typeof extensions.ftv1 !== "undefined") { + throw new Error("The `ftv1` extension was already present."); + } + + extensions.ftv1 = encodedBuffer.toString('base64'); + } + } + }, + } +}; + +export default federatedPlugin; diff --git a/packages/apollo-engine-reporting/src/index.ts b/packages/apollo-engine-reporting/src/index.ts index 5770edf02c3..82b73e73ad9 100644 --- a/packages/apollo-engine-reporting/src/index.ts +++ b/packages/apollo-engine-reporting/src/index.ts @@ -1,2 +1,2 @@ export { EngineReportingOptions, EngineReportingAgent } from './agent'; -export { EngineFederatedTracingExtension } from './federatedExtension'; +export { default as federatedPlugin } from './federatedPlugin'; diff --git a/packages/apollo-engine-reporting/src/plugin.ts b/packages/apollo-engine-reporting/src/plugin.ts new file mode 100644 index 00000000000..b960df381df --- /dev/null +++ b/packages/apollo-engine-reporting/src/plugin.ts @@ -0,0 +1,420 @@ +import { + GraphQLRequestContext, + Logger, + GraphQLRequestContextDidEncounterErrors, + GraphQLRequestContextWillSendResponse, +} from 'apollo-server-types'; +import { Headers } from 'apollo-server-env'; +import { GraphQLSchema, printSchema } from 'graphql'; +import { Trace } from 'apollo-engine-reporting-protobuf'; + +import { + AddTraceArgs, + EngineReportingOptions, + GenerateClientInfo, + SendValuesBaseOptions, + VariableValueOptions, +} from './agent'; +import { EngineReportingTreeBuilder } from './treeBuilder'; +import { ApolloServerPlugin } from 'apollo-server-plugin-base'; + +const clientNameHeaderKey = 'apollographql-client-name'; +const clientReferenceIdHeaderKey = 'apollographql-client-reference-id'; +const clientVersionHeaderKey = 'apollographql-client-version'; + +// This plugin is instantiated once at server start-up. Each request that the +// server processes will invoke the `requestDidStart` method which will produce +// a trace (in protobuf Trace format) for that single request. When the request +// is done, it passes the Trace back to its associated EngineReportingAgent via +// the addTrace callback. This class isn't for direct use; its constructor is a +// private API for communicating with EngineReportingAgent. +export const plugin = ( + options: EngineReportingOptions = Object.create(null), + addTrace: (args: AddTraceArgs) => Promise, + { + startSchemaReporting, + executableSchemaIdGenerator, + }: { + startSchemaReporting: ({ + executableSchema, + executableSchemaId, + }: { + executableSchema: string; + executableSchemaId: string; + }) => void; + executableSchemaIdGenerator: (schema: string | GraphQLSchema) => string; + }, +): ApolloServerPlugin => { + /** + * Non request-specific logging will go into this general logger. Request- + * specific log output (where the log output is only a result of a specific + * request) will go to the `logger` which we get from the request context. + */ + const loggerForPlugin: Logger = options.logger || console; + + const generateClientInfo: GenerateClientInfo = + options.generateClientInfo || defaultGenerateClientInfo; + + return { + serverWillStart: function({ schema }) { + if (!options.experimental_schemaReporting) return; + startSchemaReporting({ + executableSchema: + options.experimental_overrideReportedSchema || printSchema(schema), + executableSchemaId: executableSchemaIdGenerator( + options.experimental_overrideReportedSchema || schema, + ), + }); + }, + requestDidStart({ + logger: requestLogger, + metrics, + schema, + request: { http, variables }, + }) { + /** + * Request specific log output should go into the `logger` from the + * request context when it's provided. + */ + const logger = requestLogger || loggerForPlugin; + + const treeBuilder: EngineReportingTreeBuilder = new EngineReportingTreeBuilder( + { + rewriteError: options.rewriteError, + logger, + }, + ); + + treeBuilder.startTiming(); + + metrics.startHrTime = treeBuilder.startHrTime; + + if (http) { + treeBuilder.trace.http = new Trace.HTTP({ + method: + Trace.HTTP.Method[http.method as keyof typeof Trace.HTTP.Method] || + Trace.HTTP.Method.UNKNOWN, + // Host and path are not used anywhere on the backend, so let's not bother + // trying to parse request.url to get them, which is a potential + // source of bugs because integrations have different behavior here. + // On Node's HTTP module, request.url only includes the path + // (see https://nodejs.org/api/http.html#http_message_url) + // The same is true on Lambda (where we pass event.path) + // But on environments like Cloudflare we do get a complete URL. + host: null, + path: null, + }); + + if (options.sendHeaders) { + makeHTTPRequestHeaders( + treeBuilder.trace.http, + http.headers, + options.sendHeaders, + ); + } + } + + /** + * Due to a number of exceptions in the request pipeline — which are + * intended to preserve backwards compatible behavior with the + * first generation of the request pipeline plugins prior to the + * introduction of `didEncounterErrors` — we need to have this "didEnd" + * functionality invoked from two places. This accounts for the fact + * that sometimes, under some special-cased error conditions, + * `willSendResponse` is not invoked. To zoom in on some of these cases, + * check the `requestPipeline.ts` for `emitErrorAndThrow`. + */ + let endDone: boolean = false; + function didEnd( + requestContext: + | GraphQLRequestContextWillSendResponse + | GraphQLRequestContextDidEncounterErrors, + ) { + if (endDone) return; + endDone = true; + treeBuilder.stopTiming(); + + treeBuilder.trace.fullQueryCacheHit = !!metrics.responseCacheHit; + treeBuilder.trace.forbiddenOperation = !!metrics.forbiddenOperation; + treeBuilder.trace.registeredOperation = !!metrics.registeredOperation; + + // If operation resolution (parsing and validating the document followed + // by selecting the correct operation) resulted in the population of the + // `operationName`, we'll use that. (For anonymous operations, + // `requestContext.operationName` is null, which we represent here as + // the empty string.) + // + // If the user explicitly specified an `operationName` in their request + // but operation resolution failed (due to parse or validation errors or + // because there is no operation with that name in the document), we + // still put _that_ user-supplied `operationName` in the trace. This + // allows the error to be better understood in Graph Manager. (We are + // considering changing the behavior of `operationName` in these 3 error + // cases; https://github.com/apollographql/apollo-server/pull/3465) + const operationName = + requestContext.operationName || + requestContext.request.operationName || + ''; + + // If this was a federated operation and we're the gateway, add the query plan + // to the trace. + if (metrics.queryPlanTrace) { + treeBuilder.trace.queryPlan = metrics.queryPlanTrace; + } + + // Intentionally un-awaited so as not to block the response. Any + // errors will be logged, but will not manifest a user-facing error. + // The logger in this case is a request specific logger OR the logger + // defined by the plugin if that's unavailable. The request-specific + // logger is preferred since this is very much coupled directly to a + // client-triggered action which might be more granularly tagged by + // logging implementations. + addTrace({ + operationName, + queryHash: requestContext.queryHash!, + document: requestContext.document, + source: requestContext.source, + trace: treeBuilder.trace, + executableSchemaId: executableSchemaIdGenerator( + options.experimental_overrideReportedSchema || schema, + ), + logger, + }).catch(logger.error); + } + + // While we start the tracing as soon as possible, we only actually report + // traces when we have resolved the source. This is largely because of + // the APQ negotiation that takes place before that resolution happens. + // This is effectively bypassing the reporting of: + // - PersistedQueryNotFoundError + // - PersistedQueryNotSupportedError + // - InvalidGraphQLRequestError + let didResolveSource: boolean = false; + + return { + didResolveSource(requestContext) { + didResolveSource = true; + + if (metrics.persistedQueryHit) { + treeBuilder.trace.persistedQueryHit = true; + } + if (metrics.persistedQueryRegister) { + treeBuilder.trace.persistedQueryRegister = true; + } + + if (variables) { + treeBuilder.trace.details = makeTraceDetails( + variables, + options.sendVariableValues, + requestContext.source, + ); + } + + const clientInfo = generateClientInfo(requestContext); + if (clientInfo) { + // While clientAddress could be a part of the protobuf, we'll ignore + // it for now, since the backend does not group by it and Graph + // Manager will not support it in the short term + 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 || ''; + treeBuilder.trace.clientReferenceId = clientReferenceId || ''; + treeBuilder.trace.clientName = clientName || ''; + } + }, + + executionDidStart() { + return { + willResolveField({ info }) { + return treeBuilder.willResolveField(info); + // We could save the error into the trace during the end handler, but + // it won't have all the information that graphql-js adds to it later, + // like 'locations'. + }, + }; + }, + + willSendResponse(requestContext) { + // See comment above for why `didEnd` must be called in two hooks. + didEnd(requestContext); + }, + + didEncounterErrors(requestContext) { + // Search above for a comment about "didResolveSource" to see which + // of the pre-source-resolution errors we are intentionally avoiding. + if (!didResolveSource) return; + treeBuilder.didEncounterErrors(requestContext.errors); + + // See comment above for why `didEnd` must be called in two hooks. + didEnd(requestContext); + }, + }; + }, + }; +}; + +// Helpers for producing traces. + +function defaultGenerateClientInfo({ request }: GraphQLRequestContext) { + // Default to using the `apollo-client-x` header fields if present. + // If none are present, fallback on the `clientInfo` query extension + // for backwards compatibility. + // The default value if neither header values nor query extension is + // set is the empty String for all fields (as per protobuf defaults) + if ( + request.http && + request.http.headers && + (request.http.headers.get(clientNameHeaderKey) || + request.http.headers.get(clientVersionHeaderKey) || + request.http.headers.get(clientReferenceIdHeaderKey)) + ) { + return { + clientName: request.http.headers.get(clientNameHeaderKey), + clientVersion: request.http.headers.get(clientVersionHeaderKey), + clientReferenceId: request.http.headers.get(clientReferenceIdHeaderKey), + }; + } else if (request.extensions && request.extensions.clientInfo) { + return request.extensions.clientInfo; + } else { + return {}; + } +} + +// Creates trace details from request variables, given a specification for modifying +// values of private or sensitive variables. +// The details will include all variable names and their (possibly hidden or modified) values. +// If sendVariableValues is {all: bool}, {none: bool} or {exceptNames: Array}, the option will act similarly to +// to the to-be-deprecated options.privateVariables, except that the redacted variable +// names will still be visible in the UI even if the values are hidden. +// If sendVariableValues is null or undefined, we default to the {none: true} case. +export function makeTraceDetails( + variables: Record, + sendVariableValues?: VariableValueOptions, + operationString?: string, +): Trace.Details { + const details = new Trace.Details(); + const variablesToRecord = (() => { + if (sendVariableValues && 'transform' in sendVariableValues) { + const originalKeys = Object.keys(variables); + try { + // Custom function to allow user to specify what variablesJson will look like + const modifiedVariables = sendVariableValues.transform({ + variables: variables, + operationString: operationString, + }); + return cleanModifiedVariables(originalKeys, modifiedVariables); + } catch (e) { + // If the custom function provided by the user throws an exception, + // change all the variable values to an appropriate error message. + return handleVariableValueTransformError(originalKeys); + } + } else { + return variables; + } + })(); + + // Note: we explicitly do *not* include the details.rawQuery field. The + // Engine web app currently does nothing with this other than store it in + // the database and offer it up via its GraphQL API, and sending it means + // that using calculateSignature to hide sensitive data in the query + // string is ineffective. + Object.keys(variablesToRecord).forEach(name => { + if ( + !sendVariableValues || + ('none' in sendVariableValues && sendVariableValues.none) || + ('all' in sendVariableValues && !sendVariableValues.all) || + ('exceptNames' in sendVariableValues && + // We assume that most users will have only a few variables values to hide, + // or will just set {none: true}; we can change this + // linear-time operation if it causes real performance issues. + sendVariableValues.exceptNames.includes(name)) || + ('onlyNames' in sendVariableValues && + !sendVariableValues.onlyNames.includes(name)) + ) { + // Special case for private variables. Note that this is a different + // representation from a variable containing the empty string, as that + // will be sent as '""'. + details.variablesJson![name] = ''; + } else { + try { + details.variablesJson![name] = + typeof variablesToRecord[name] === 'undefined' + ? '' + : JSON.stringify(variablesToRecord[name]); + } catch (e) { + details.variablesJson![name] = JSON.stringify( + '[Unable to convert value to JSON]', + ); + } + } + }); + return details; +} + +function handleVariableValueTransformError( + variableNames: string[], +): Record { + const modifiedVariables = Object.create(null); + variableNames.forEach(name => { + modifiedVariables[name] = '[PREDICATE_FUNCTION_ERROR]'; + }); + return modifiedVariables; +} + +// Helper for makeTraceDetails() to enforce that the keys of a modified 'variables' +// matches that of the original 'variables' +function cleanModifiedVariables( + originalKeys: Array, + modifiedVariables: Record, +): Record { + const cleanedVariables: Record = Object.create(null); + originalKeys.forEach(name => { + cleanedVariables[name] = modifiedVariables[name]; + }); + return cleanedVariables; +} + +export function makeHTTPRequestHeaders( + http: Trace.IHTTP, + headers: Headers, + sendHeaders?: SendValuesBaseOptions, +): void { + if ( + !sendHeaders || + ('none' in sendHeaders && sendHeaders.none) || + ('all' in sendHeaders && !sendHeaders.all) + ) { + return; + } + for (const [key, value] of headers) { + const lowerCaseKey = key.toLowerCase(); + if ( + ('exceptNames' in sendHeaders && + // We assume that most users only have a few headers to hide, or will + // just set {none: true} ; we can change this linear-time + // operation if it causes real performance issues. + sendHeaders.exceptNames.some(exceptHeader => { + // Headers are case-insensitive, and should be compared as such. + return exceptHeader.toLowerCase() === lowerCaseKey; + })) || + ('onlyNames' in sendHeaders && + !sendHeaders.onlyNames.some(header => { + return header.toLowerCase() === lowerCaseKey; + })) + ) { + continue; + } + + switch (key) { + case 'authorization': + case 'cookie': + case 'set-cookie': + break; + default: + http!.requestHeaders![key] = new Trace.HTTP.Values({ + value: [value], + }); + } + } +} diff --git a/packages/apollo-engine-reporting/src/reportingOperationTypes.ts b/packages/apollo-engine-reporting/src/reportingOperationTypes.ts new file mode 100644 index 00000000000..1bf7b182d51 --- /dev/null +++ b/packages/apollo-engine-reporting/src/reportingOperationTypes.ts @@ -0,0 +1,64 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: AutoregReportServerInfo +// ==================================================== + +import { GraphQLFormattedError } from 'graphql'; + +export interface ReportServerInfo_me_UserMutation { + __typename: 'UserMutation'; +} + +export interface ReportServerInfo_me_ServiceMutation_reportServerInfo { + __typename: 'ReportServerInfoResponse'; + inSeconds: number; + withExecutableSchema: boolean; +} + +export interface ReportServerInfo_me_ServiceMutation { + __typename: 'ServiceMutation'; + /** + * Schema auto-registration. Private alpha. + */ + reportServerInfo: ReportServerInfo_me_ServiceMutation_reportServerInfo | null; +} + +export type ReportServerInfo_me = + | ReportServerInfo_me_UserMutation + | ReportServerInfo_me_ServiceMutation; + +export interface SchemaReportingServerInfo { + me: ReportServerInfo_me | null; +} + +export interface SchemaReportingServerInfoResult { + data?: SchemaReportingServerInfo; + errors?: ReadonlyArray; +} + +export interface ReportServerInfoVariables { + info: EdgeServerInfo; + executableSchema?: string | null; +} + +/** + * Edge server info + */ +export interface EdgeServerInfo { + bootId: string; + executableSchemaId: string; + graphVariant: string; + libraryVersion?: string | null; + platform?: string | null; + runtimeVersion?: string | null; + serverId?: string | null; + userVersion?: string | null; +} + +//============================================================== +// END Enums and Input Objects +//============================================================== diff --git a/packages/apollo-engine-reporting/src/schemaReporter.ts b/packages/apollo-engine-reporting/src/schemaReporter.ts new file mode 100644 index 00000000000..5dae15d7b61 --- /dev/null +++ b/packages/apollo-engine-reporting/src/schemaReporter.ts @@ -0,0 +1,190 @@ +import { + ReportServerInfoVariables, + EdgeServerInfo, + SchemaReportingServerInfoResult, +} from './reportingOperationTypes'; +import { fetch, Headers, Request } from 'apollo-server-env'; +import { GraphQLRequest, Logger } from 'apollo-server-types'; + +export const reportServerInfoGql = ` + mutation ReportServerInfo($info: EdgeServerInfo!, $executableSchema: String) { + me { + __typename + ... on ServiceMutation { + reportServerInfo(info: $info, executableSchema: $executableSchema) { + inSeconds + withExecutableSchema + } + } + } + } +`; + +export function reportingLoop( + schemaReporter: SchemaReporter, + logger: Logger, + sendNextWithExecutableSchema: boolean, + fallbackReportingDelayInMs: number, +) { + function inner() { + // Bail out permanently + if (schemaReporter.stopped()) return; + + // Not awaiting this. The callback is handled in the `then` and it calls inner() + // to report the server info in however many seconds we were told to wait from + // Apollo Graph Manager + schemaReporter + .reportServerInfo(sendNextWithExecutableSchema) + .then(({ inSeconds, withExecutableSchema }) => { + sendNextWithExecutableSchema = withExecutableSchema; + setTimeout(inner, inSeconds * 1000); + }) + .catch((error: any) => { + // In the case of an error we want to continue looping + // We can add hardcoded backoff in the future, + // or on repeated failures stop responding reporting. + logger.error( + `Error reporting server info to Apollo Graph Manager during schema reporting: ${error}`, + ); + sendNextWithExecutableSchema = false; + setTimeout(inner, fallbackReportingDelayInMs); + }); + } + + inner(); +} + +interface ReportServerInfoReturnVal { + inSeconds: number; + withExecutableSchema: boolean; +} + +// This class is meant to be a thin shim around the gql mutations. +export class SchemaReporter { + // These mirror the gql variables + private readonly serverInfo: EdgeServerInfo; + private readonly executableSchemaDocument: any; + private readonly url: string; + + private isStopped: boolean; + private readonly headers: Headers; + + constructor( + serverInfo: EdgeServerInfo, + schemaSdl: string, + apiKey: string, + schemaReportingEndpoint: string | undefined, + ) { + this.headers = new Headers(); + this.headers.set('Content-Type', 'application/json'); + this.headers.set('x-api-key', apiKey); + this.headers.set('apollographql-client-name', 'apollo-engine-reporting'); + this.headers.set( + 'apollographql-client-version', + require('../package.json').version, + ); + + this.url = + schemaReportingEndpoint || + 'https://engine-graphql.apollographql.com/api/graphql'; + + this.serverInfo = serverInfo; + this.executableSchemaDocument = schemaSdl; + this.isStopped = false; + } + + public stopped(): Boolean { + return this.isStopped; + } + + public stop() { + this.isStopped = true; + } + + public async reportServerInfo( + withExecutableSchema: boolean, + ): Promise { + const { data, errors } = await this.graphManagerQuery({ + info: this.serverInfo, + executableSchema: withExecutableSchema + ? this.executableSchemaDocument + : null, + }); + + if (errors) { + throw new Error((errors || []).map((x: any) => x.message).join('\n')); + } + + function msgForUnexpectedResponse(data: any): string { + return [ + 'Unexpected response shape from Apollo Graph Manager when', + 'reporting server information for schema reporting. If', + 'this continues, please reach out to support@apollographql.com.', + 'Received response:', + JSON.stringify(data), + ].join(' '); + } + + if (!data || !data.me || !data.me.__typename) { + throw new Error(msgForUnexpectedResponse(data)); + } + + if (data.me.__typename === 'UserMutation') { + this.isStopped = true; + throw new Error( + [ + 'This server was configured with an API key for a user.', + "Only a service's API key may be used for schema reporting.", + 'Please visit the settings for this graph at', + 'https://engine.apollographql.com/ to obtain an API key for a service.', + ].join(' '), + ); + } else if (data.me.__typename === 'ServiceMutation') { + if (!data.me.reportServerInfo) { + throw new Error(msgForUnexpectedResponse(data)); + } + return data.me.reportServerInfo; + } else { + throw new Error(msgForUnexpectedResponse(data)); + } + } + + private async graphManagerQuery( + variables: ReportServerInfoVariables, + ): Promise { + const request: GraphQLRequest = { + query: reportServerInfoGql, + operationName: 'ReportServerInfo', + variables: variables, + }; + const httpRequest = new Request(this.url, { + method: 'POST', + headers: this.headers, + body: JSON.stringify(request), + }); + + const httpResponse = await fetch(httpRequest); + + if (!httpResponse.ok) { + throw new Error([ + `An unexpected HTTP status code (${httpResponse.status}) was`, + 'encountered during schema reporting.' + ].join(' ')); + } + + try { + // JSON parsing failure due to malformed data is the likely failure case + // here. Any non-JSON response (e.g. HTML) is usually the suspect. + return await httpResponse.json(); + } catch (error) { + throw new Error( + [ + "Couldn't report server info to Apollo Graph Manager.", + 'Parsing response as JSON failed.', + 'If this continues please reach out to support@apollographql.com', + error + ].join(' '), + ); + } + } +} diff --git a/packages/apollo-engine-reporting/src/treeBuilder.ts b/packages/apollo-engine-reporting/src/treeBuilder.ts index a10535bbe23..27bfe9376ba 100644 --- a/packages/apollo-engine-reporting/src/treeBuilder.ts +++ b/packages/apollo-engine-reporting/src/treeBuilder.ts @@ -1,10 +1,6 @@ import { GraphQLError, GraphQLResolveInfo, ResponsePath } from 'graphql'; import { Trace, google } from 'apollo-engine-reporting-protobuf'; -import { - PersistedQueryNotFoundError, - PersistedQueryNotSupportedError, -} from 'apollo-server-errors'; -import { InvalidGraphQLRequestError, Logger } from 'apollo-server-types'; +import { Logger } from 'apollo-server-types'; function internalError(message: string) { return new Error(`[internal apollo-server error] ${message}`); @@ -78,16 +74,8 @@ export class EngineReportingTreeBuilder { }; } - public didEncounterErrors(errors: GraphQLError[]) { + public didEncounterErrors(errors: readonly GraphQLError[]) { errors.forEach(err => { - if ( - err instanceof PersistedQueryNotFoundError || - err instanceof PersistedQueryNotSupportedError || - err instanceof InvalidGraphQLRequestError - ) { - return; - } - // This is an error from a federated service. We will already be reporting // it in the nested Trace in the query plan. // diff --git a/packages/apollo-engine-reporting/tsconfig.json b/packages/apollo-engine-reporting/tsconfig.json index ca382f1094c..ea64c712362 100644 --- a/packages/apollo-engine-reporting/tsconfig.json +++ b/packages/apollo-engine-reporting/tsconfig.json @@ -7,8 +7,8 @@ "include": ["src/**/*"], "exclude": ["**/__tests__", "**/__mocks__"], "references": [ - { "path": "../graphql-extensions" }, { "path": "../apollo-server-errors" }, { "path": "../apollo-server-types" }, + { "path": "../apollo-server-plugin-base" }, ] } diff --git a/packages/apollo-federation/CHANGELOG.md b/packages/apollo-federation/CHANGELOG.md index de1b2d3c5d5..a0dc25f3aef 100644 --- a/packages/apollo-federation/CHANGELOG.md +++ b/packages/apollo-federation/CHANGELOG.md @@ -6,6 +6,10 @@ - _Nothing yet! Stay tuned._ +## 0.16.1 + +- Only changes in the similarly versioned `@apollo/gateway` package. + ## 0.16.0 - No changes. This package was major versioned to maintain lockstep versioning with @apollo/gateway. diff --git a/packages/apollo-federation/package.json b/packages/apollo-federation/package.json index 4017608c051..f978561161e 100644 --- a/packages/apollo-federation/package.json +++ b/packages/apollo-federation/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/federation", - "version": "0.16.0", + "version": "0.16.1", "description": "Apollo Federation Utilities", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-gateway/CHANGELOG.md b/packages/apollo-gateway/CHANGELOG.md index 079a262ce68..e2d799a7bf6 100644 --- a/packages/apollo-gateway/CHANGELOG.md +++ b/packages/apollo-gateway/CHANGELOG.md @@ -6,6 +6,13 @@ - __FIX__: Collapse nested required fields into a single body in the query plan. Before, some nested fields' selection sets were getting split, causing some of their subfields to be dropped when executing the query. This fix collapses the split selection sets into one. [#4064](https://github.com/apollographql/apollo-server/pull/4064) +## 0.16.1 + +- __NEW__: Provide the ability to pass a custom `fetcher` during `RemoteGraphQLDataSource` construction to be used when executing operations against downstream services. Providing a custom `fetcher` may be necessary to accommodate more advanced needs, e.g., configuring custom TLS certificates for internal services. [PR #4149](https://github.com/apollographql/apollo-server/pull/4149) + + The `fetcher` specified should be a compliant implementor of the [Fetch API standard](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). This addition compliments, though is still orthognonal to, similar behavior originally introduced in [#3783](https://github.com/apollographql/apollo-server/pull/3783), which allowed customization of the implementation used to fetch _gateway configuration and federated SDL from services_ in managed and unmanaged modes, but didn't affect the communication that takes place during _operation execution_. + + For now, the default `fetcher` will remain the same ([`node-fetch`](https://npm.im/node-fetch)) implementation. A future major-version bump will update it to be consistent with other feature-rich implementations of the Fetch API which are used elsewhere in the Apollo Server stack where we use [`make-fetch-happen`](https://npm.im/make-fetch-happen). In all likelihood, `ApolloGateway` will pass its own `fetcher` to the `RemoteGraphQLDataSource` during service initialization. ## 0.16.0 diff --git a/packages/apollo-gateway/package.json b/packages/apollo-gateway/package.json index da61a3b74cd..ffa8c1c9b73 100644 --- a/packages/apollo-gateway/package.json +++ b/packages/apollo-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/gateway", - "version": "0.16.0", + "version": "0.16.1", "description": "Apollo Gateway", "author": "opensource@apollographql.com", "main": "dist/index.js", diff --git a/packages/apollo-gateway/src/datasources/RemoteGraphQLDataSource.ts b/packages/apollo-gateway/src/datasources/RemoteGraphQLDataSource.ts index feffde81b2d..700e78e2f47 100644 --- a/packages/apollo-gateway/src/datasources/RemoteGraphQLDataSource.ts +++ b/packages/apollo-gateway/src/datasources/RemoteGraphQLDataSource.ts @@ -20,6 +20,8 @@ import { GraphQLDataSource } from './types'; import createSHA from 'apollo-server-core/dist/utils/createSHA'; export class RemoteGraphQLDataSource = Record> implements GraphQLDataSource { + fetcher: typeof fetch = fetch; + constructor( config?: Partial> & object & @@ -144,7 +146,8 @@ export class RemoteGraphQLDataSource = Reco }); try { - const httpResponse = await fetch(httpRequest); + // Use our local `fetcher` to allow for fetch injection + const httpResponse = await this.fetcher(httpRequest); if (!httpResponse.ok) { throw await this.errorFromResponse(httpResponse); diff --git a/packages/apollo-gateway/src/datasources/__tests__/RemoteGraphQLDataSource.test.ts b/packages/apollo-gateway/src/datasources/__tests__/RemoteGraphQLDataSource.test.ts index ff5309dc000..f81b39afb18 100644 --- a/packages/apollo-gateway/src/datasources/__tests__/RemoteGraphQLDataSource.test.ts +++ b/packages/apollo-gateway/src/datasources/__tests__/RemoteGraphQLDataSource.test.ts @@ -9,6 +9,7 @@ import { import { RemoteGraphQLDataSource } from '../RemoteGraphQLDataSource'; import { Headers } from 'apollo-server-env'; import { GraphQLRequestContext } from 'apollo-server-types'; +import { Response } from '../../../../../../apollo-tooling/packages/apollo-env/lib'; beforeEach(() => { fetch.mockReset(); @@ -238,6 +239,29 @@ describe('constructing requests', () => { }); }); +describe('fetcher', () => { + it('uses a custom provided `fetcher`', async () => { + const injectedFetch = fetch.mockJSONResponseOnce({ data: { injected: true } }); + const DataSource = new RemoteGraphQLDataSource({ + url: 'https://api.example.com/foo', + fetcher: injectedFetch, + }); + + const { data } = await DataSource.process({ + request: { + query: '{ me { name } }', + variables: { id: '1' }, + }, + context: {}, + }); + + expect(injectedFetch).toHaveBeenCalled(); + expect(data).toEqual({injected: true}); + + }); + +}); + describe('willSendRequest', () => { it('allows for modifying variables', async () => { const DataSource = new RemoteGraphQLDataSource({ diff --git a/packages/apollo-gateway/src/executeQueryPlan.ts b/packages/apollo-gateway/src/executeQueryPlan.ts index 902c6552902..b92aec7d61e 100644 --- a/packages/apollo-gateway/src/executeQueryPlan.ts +++ b/packages/apollo-gateway/src/executeQueryPlan.ts @@ -89,11 +89,9 @@ export async function executeQueryPlan( }, rootValue: data, variableValues: requestContext.request.variables, - // FIXME: GraphQL extensions currently wraps every field and creates - // a field resolver. Because of this, when using with ApolloServer - // the defaultFieldResolver isn't called. We keep this here - // because it is the correct solution and when ApolloServer removes - // GraphQLExtensions this will be how alias support is maintained + // We have a special field resolver which ensures we support aliases. + // FIXME: It's _possible_ this will change after `graphql-extensions` is + // deprecated, though not certain. See here, also: https://git.io/Jf8cS. fieldResolver: defaultFieldResolverWithAliasSupport, })); } catch (error) { diff --git a/packages/apollo-gateway/src/index.ts b/packages/apollo-gateway/src/index.ts index 1daf6097d69..d74269f0299 100644 --- a/packages/apollo-gateway/src/index.ts +++ b/packages/apollo-gateway/src/index.ts @@ -476,6 +476,10 @@ export class ApolloGateway implements GraphQLService { this.logger.debug('Schema loaded and ready for execution'); + // FIXME: The comment below may change when `graphql-extensions` is + // removed, as it will be soon. It's not clear if this will be temporary, + // as is suggested, after that time, because we still very much need to + // do this special alias resolving. Original comment: // this is a temporary workaround for GraphQLFieldExtensions automatic // wrapping of all fields when using ApolloServer. Here we wrap all fields // with support for resolving aliases as part of the root value which diff --git a/packages/apollo-server-azure-functions/package.json b/packages/apollo-server-azure-functions/package.json index eadf0e112d7..eff4023a287 100644 --- a/packages/apollo-server-azure-functions/package.json +++ b/packages/apollo-server-azure-functions/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-azure-functions", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Azure Functions", "keywords": [ "GraphQL", diff --git a/packages/apollo-server-cache-memcached/package.json b/packages/apollo-server-cache-memcached/package.json index f83ec47ec35..233ac509ecd 100644 --- a/packages/apollo-server-cache-memcached/package.json +++ b/packages/apollo-server-cache-memcached/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-cache-memcached", - "version": "0.6.5-alpha.0", + "version": "0.6.5", "author": "opensource@apollographql.com", "license": "MIT", "repository": { diff --git a/packages/apollo-server-cache-redis/package.json b/packages/apollo-server-cache-redis/package.json index b0b57a1f926..3d1cef1cdef 100644 --- a/packages/apollo-server-cache-redis/package.json +++ b/packages/apollo-server-cache-redis/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-cache-redis", - "version": "1.2.1-alpha.0", + "version": "1.2.1", "author": "opensource@apollographql.com", "license": "MIT", "repository": { diff --git a/packages/apollo-server-cloud-functions/package.json b/packages/apollo-server-cloud-functions/package.json index 5a052be9758..03bb8b3f0c9 100644 --- a/packages/apollo-server-cloud-functions/package.json +++ b/packages/apollo-server-cloud-functions/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-cloud-functions", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Google Cloud Functions", "keywords": [ "GraphQL", diff --git a/packages/apollo-server-cloudflare/package.json b/packages/apollo-server-cloudflare/package.json index 269af38d556..27a50cda92f 100644 --- a/packages/apollo-server-cloudflare/package.json +++ b/packages/apollo-server-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-cloudflare", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Cloudflare workers", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-core/package.json b/packages/apollo-server-core/package.json index 941fc28ffb3..0d7b007db9e 100644 --- a/packages/apollo-server-core/package.json +++ b/packages/apollo-server-core/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-core", - "version": "2.13.1", + "version": "2.14.0", "description": "Core engine for Apollo GraphQL server", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index f5de66b3c20..18181de0eb9 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -71,8 +71,13 @@ import { import { Headers } from 'apollo-server-env'; import { buildServiceDefinition } from '@apollographql/apollo-tools'; +import { plugin as pluginTracing } from "apollo-tracing"; +import { Logger, SchemaHash } from "apollo-server-types"; +import { + plugin as pluginCacheControl, + CacheControlExtensionOptions, +} from 'apollo-cache-control'; import { getEngineApiKey, getEngineGraphVariant } from "apollo-engine-reporting/dist/agent"; -import { Logger } from "apollo-server-types"; const NoIntrospection = (context: ValidationContext) => ({ Field(node: FieldDefinitionNode) { @@ -109,7 +114,7 @@ type SchemaDerivedData = { // on the same operation to be executed immediately. documentStore?: InMemoryLRUCache; schema: GraphQLSchema; - schemaHash: string; + schemaHash: SchemaHash; extensions: Array<() => GraphQLExtension>; }; @@ -165,6 +170,7 @@ export class ApolloServerBase { playground, plugins, gateway, + cacheControl, experimental_approximateDocumentStoreMiB, ...requestOptions } = config; @@ -200,10 +206,6 @@ export class ApolloServerBase { this.parseOptions = parseOptions; this.context = context; - // Plugins will be instantiated if they aren't already, and this.plugins - // is populated accordingly. - this.ensurePluginInstantiation(plugins); - // While reading process.env is slow, a server should only be constructed // once per run, so we place the env check inside the constructor. If env // should be used outside of the constructor context, place it as a private @@ -224,31 +226,6 @@ export class ApolloServerBase { : noIntro; } - if (requestOptions.cacheControl !== false) { - if ( - typeof requestOptions.cacheControl === 'boolean' && - requestOptions.cacheControl === true - ) { - // cacheControl: true means that the user needs the cache-control - // extensions. This means we are running the proxy, so we should not - // strip out the cache control extension and not add cache-control headers - requestOptions.cacheControl = { - stripFormattedExtensions: false, - calculateHttpHeaders: false, - defaultMaxAge: 0, - }; - } else { - // Default behavior is to run default header calculation and return - // no cacheControl extensions - requestOptions.cacheControl = { - stripFormattedExtensions: true, - calculateHttpHeaders: true, - defaultMaxAge: 0, - ...requestOptions.cacheControl, - }; - } - } - if (!requestOptions.cache) { requestOptions.cache = new InMemoryLRUCache(); } @@ -388,6 +365,11 @@ export class ApolloServerBase { } else { throw new Error("Unexpected error: Unable to resolve a valid GraphQLSchema. Please file an issue with a reproduction of this error, if possible."); } + + // Plugins will be instantiated if they aren't already, and this.plugins + // is populated accordingly. + this.ensurePluginInstantiation(plugins); + } // used by integrations to synchronize the path with subscriptions, some @@ -537,39 +519,6 @@ export class ApolloServerBase { const extensions = []; - const schemaIsFederated = this.schemaIsFederated(schema); - const { engine } = this.config; - // Keep this extension second so it wraps everything, except error formatting - if (this.engineReportingAgent) { - if (schemaIsFederated) { - // XXX users can configure a federated Apollo Server to send metrics, but the - // Gateway should be responsible for that. It's possible that users are running - // their own gateway or running a federated service on its own. Nonetheless, in - // the likely case it was accidental, we warn users that they should only report - // metrics from the Gateway. - this.logger.warn( - "It looks like you're running a federated schema and you've configured your service " + - 'to report metrics to Apollo Graph Manager. You should only configure your Apollo gateway ' + - 'to report metrics to Apollo Graph Manager.', - ); - } - extensions.push(() => - this.engineReportingAgent!.newExtension(schemaHash), - ); - } else if (engine !== false && schemaIsFederated) { - // We haven't configured this app to use Engine directly. But it looks like - // we are a federated service backend, so we should be capable of including - // our trace in a response extension if we are asked to by the gateway. - const { - EngineFederatedTracingExtension, - } = require('apollo-engine-reporting'); - const rewriteError = - engine && typeof engine === 'object' ? engine.rewriteError : undefined; - extensions.push( - () => new EngineFederatedTracingExtension({ rewriteError }), - ); - } - // Note: doRunQuery will add its own extensions if you set tracing, // or cacheControl. extensions.push(...(_extensions || [])); @@ -759,12 +708,77 @@ export class ApolloServerBase { return sdlFieldType.name == 'String'; } - private ensurePluginInstantiation(plugins?: PluginDefinition[]): void { - if (!plugins || !plugins.length) { - return; + private ensurePluginInstantiation(plugins: PluginDefinition[] = []): void { + const pluginsToInit: PluginDefinition[] = []; + + // Internal plugins should be added to `pluginsToInit` here. + // User's plugins, provided as an argument to this method, will be added + // at the end of that list so they take precedence. + + // If the user has enabled it explicitly, add our tracing lugin. + if (this.config.tracing) { + pluginsToInit.push(pluginTracing()) + } + + // Enable cache control unless it was explicitly disabled. + if (this.config.cacheControl !== false) { + let cacheControlOptions: CacheControlExtensionOptions = {}; + if ( + typeof this.config.cacheControl === 'boolean' && + this.config.cacheControl === true + ) { + // cacheControl: true means that the user needs the cache-control + // extensions. This means we are running the proxy, so we should not + // strip out the cache control extension and not add cache-control headers + cacheControlOptions = { + stripFormattedExtensions: false, + calculateHttpHeaders: false, + defaultMaxAge: 0, + }; + } else { + // Default behavior is to run default header calculation and return + // no cacheControl extensions + cacheControlOptions = { + stripFormattedExtensions: true, + calculateHttpHeaders: true, + defaultMaxAge: 0, + ...this.config.cacheControl, + }; + } + + pluginsToInit.push(pluginCacheControl(cacheControlOptions)); + } + + const federatedSchema = this.schema && this.schemaIsFederated(this.schema); + const { engine } = this.config; + // Keep this extension second so it wraps everything, except error formatting + if (this.engineReportingAgent) { + if (federatedSchema) { + // XXX users can configure a federated Apollo Server to send metrics, but the + // Gateway should be responsible for that. It's possible that users are running + // their own gateway or running a federated service on its own. Nonetheless, in + // the likely case it was accidental, we warn users that they should only report + // metrics from the Gateway. + this.logger.warn( + "It looks like you're running a federated schema and you've configured your service " + + 'to report metrics to Apollo Graph Manager. You should only configure your Apollo gateway ' + + 'to report metrics to Apollo Graph Manager.', + ); + } + pluginsToInit.push(this.engineReportingAgent!.newPlugin()); + } else if (engine !== false && federatedSchema) { + // We haven't configured this app to use Engine directly. But it looks like + // we are a federated service backend, so we should be capable of including + // our trace in a response extension if we are asked to by the gateway. + const { federatedPlugin } = require('apollo-engine-reporting'); + const rewriteError = + engine && typeof engine === 'object' ? engine.rewriteError : undefined; + pluginsToInit.push(federatedPlugin({ rewriteError })); } - this.plugins = plugins.map(plugin => { + pluginsToInit.push(...plugins); + + this.plugins = pluginsToInit.map(plugin => { if (typeof plugin === 'function') { return plugin(); } @@ -792,7 +806,12 @@ export class ApolloServerBase { protected async graphQLServerOptions( integrationContextArgument?: Record, ): Promise { - const { schema, documentStore, extensions } = await this.schemaDerivedData; + const { + schema, + schemaHash, + documentStore, + extensions, + } = await this.schemaDerivedData; let context: Context = this.context ? this.context : {}; @@ -810,6 +829,7 @@ export class ApolloServerBase { return { schema, + schemaHash, logger: this.logger, plugins: this.plugins, documentStore, @@ -839,9 +859,12 @@ export class ApolloServerBase { const requestCtx: GraphQLRequestContext = { logger: this.logger, + schema: options.schema, + schemaHash: options.schemaHash, request, context: options.context || Object.create(null), cache: options.cache!, + metrics: {}, response: { http: { headers: new Headers(), diff --git a/packages/apollo-server-core/src/__tests__/runHttpQuery.test.ts b/packages/apollo-server-core/src/__tests__/runHttpQuery.test.ts index 758e38c154f..e383dfbc334 100644 --- a/packages/apollo-server-core/src/__tests__/runHttpQuery.test.ts +++ b/packages/apollo-server-core/src/__tests__/runHttpQuery.test.ts @@ -78,7 +78,7 @@ describe('runHttpQuery', () => { } }); - it('should not add no-cache headers if error is not a PersitedQuery error', () => { + it('should not add no-cache headers if error is not a PersistedQuery error', () => { try { throwHttpGraphQLError(200, [new ForbiddenError('401')]); } catch (err) { diff --git a/packages/apollo-server-core/src/__tests__/runQuery.test.ts b/packages/apollo-server-core/src/__tests__/runQuery.test.ts index d4f4640ca2c..ec6d83477dc 100644 --- a/packages/apollo-server-core/src/__tests__/runQuery.test.ts +++ b/packages/apollo-server-core/src/__tests__/runQuery.test.ts @@ -19,16 +19,29 @@ import { import { processGraphQLRequest, GraphQLRequest } from '../requestPipeline'; import { Request } from 'apollo-server-env'; import { GraphQLOptions, Context as GraphQLContext } from 'apollo-server-core'; -import { ApolloServerPlugin } from 'apollo-server-plugin-base'; -import { GraphQLRequestListener } from 'apollo-server-plugin-base'; +import { + ApolloServerPlugin, + GraphQLRequestExecutionListener, + GraphQLRequestListener, + GraphQLRequestListenerDidResolveField, + GraphQLRequestListenerExecutionDidEnd, + GraphQLRequestListenerParsingDidEnd, + GraphQLRequestListenerValidationDidEnd, + GraphQLRequestContext, +} from 'apollo-server-plugin-base'; import { InMemoryLRUCache } from 'apollo-server-caching'; +import { generateSchemaHash } from "../utils/schemaHash"; +import { Logger } from "apollo-server-types"; // This is a temporary kludge to ensure we preserve runQuery behavior with the // GraphQLRequestProcessor refactoring. // These tests will be rewritten as GraphQLRequestProcessor tests after the // refactoring is complete. -function runQuery(options: QueryOptions): Promise { +function runQuery( + options: QueryOptions, + requestContextExtra?: Partial, +): Promise { const request: GraphQLRequest = { query: options.queryString, operationName: options.operationName, @@ -37,11 +50,18 @@ function runQuery(options: QueryOptions): Promise { http: options.request, }; + const schemaHash = generateSchemaHash(schema); + return processGraphQLRequest(options, { request, + schema: options.schema, + schemaHash, + metrics: {}, + logger: console, context: options.context || {}, debug: options.debug, cache: {} as any, + ...requestContextExtra, }); } @@ -380,6 +400,88 @@ describe('runQuery', () => { } } + describe('deprecation warnings', () => { + const queryString = `{ testString }`; + async function runWithExtAndReturnLogger( + extensions: QueryOptions['extensions'], + ): Promise { + const logger = { + warn: jest.fn(() => {}), + info: console.info, + debug: console.debug, + error: console.error, + }; + + await runQuery( + { + schema, + queryString, + extensions, + request: new MockReq(), + }, + { + logger, + }, + ); + + return logger; + } + + it('warns about named extensions', async () => { + const logger = await runWithExtAndReturnLogger([ + () => new (class NamedExtension implements GraphQLExtension {})(), + ]); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] A "NamedExtension" was/)); + }); + + it('warns about anonymous extensions', async () => { + const logger = await runWithExtAndReturnLogger([ + () => new (class implements GraphQLExtension {})(), + ]); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] An anonymous extension was/)); + }); + + it('warns about anonymous class expressions', async () => { + // In other words, when the name is the name of the variable. + const anon = class implements GraphQLExtension {}; + const logger = await runWithExtAndReturnLogger([ + () => new anon(), + ]); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] A "anon" was/)); + }); + + it('warns for multiple extensions', async () => { + const logger = await runWithExtAndReturnLogger([ + () => new (class Name1Ext implements GraphQLExtension {})(), + () => new (class Name2Ext implements GraphQLExtension {})(), + ]); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] A "Name1Ext" was/)); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] A "Name2Ext" was/)); + }); + + it('warns only once', async () => { + // Will use the same extension across two invocations. + class NameExt implements GraphQLExtension {}; + + const logger1 = await runWithExtAndReturnLogger([ + () => new NameExt, + ]); + expect(logger1.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] A "NameExt" was/)); + + const logger2 = await runWithExtAndReturnLogger([ + () => new NameExt, + ]); + expect(logger2.warn).not.toHaveBeenCalledWith( + expect.stringMatching(/^\[deprecated\] A "NameExt" was/)); + }); + }); + it('creates the extension stack', async () => { const queryString = `{ testString }`; const extensions = [() => new CustomExtension()]; @@ -469,6 +571,57 @@ describe('runQuery', () => { await runOnce(); expect(requestDidStart.mock.calls.length).toBe(2); }); + + it('is called with the schema and schemaHash', async () => { + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart, + }, + ], + request: new MockReq(), + }); + + const invocation = requestDidStart.mock.calls[0][0]; + expect(invocation).toHaveProperty('schema', schema); + expect(invocation).toHaveProperty( /* Shorter as a RegExp */ + 'schemaHash', expect.stringMatching(/^8ff87f3e0/)); + }); + }); + + /** + * This tests the simple invocation of the "didResolveSource" hook, but + * doesn't test one of the primary reasons why "source" isn't guaranteed + * sooner in the request life-cycle: when "source" is populated via an APQ + * cache HIT. + * + * That functionality is tested in `apollo-server-integration-testsuite`, + * within the "Persisted Queries" tests. (Search for "didResolveSource"). + */ + describe('didResolveSource', () => { + const didResolveSource = jest.fn(); + it('called with the source', async () => { + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + didResolveSource, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(didResolveSource).toHaveBeenCalled(); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', '{ testString }'); + }); }); describe('parsingDidStart', () => { @@ -512,6 +665,407 @@ describe('runQuery', () => { }); }); + describe('executionDidStart', () => { + it('called when execution starts', async () => { + const executionDidStart = jest.fn(); + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + }); + + describe('executionDidEnd', () => { + it('works as a function returned from "executionDidStart"', async () => { + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestListenerExecutionDidEnd => executionDidEnd); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('works as a listener on an object returned from "executionDidStart"', + async () => { + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + } + ); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + }); + + describe('willResolveField', () => { + it('called when resolving a field starts', async () => { + const willResolveField = jest.fn(); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('called once for each field being resolved', async () => { + const willResolveField = jest.fn(); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString again:testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(2); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + describe('receives correct resolver parameter object', () => { + it('receives undefined parent when there is no parent', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + // It is called only once. + expect(willResolveField).toHaveBeenCalledTimes(1); + const call = willResolveField.mock.calls[0]; + expect(call[0]).toHaveProperty("source", undefined); + expect(call[0]).toHaveProperty("info.path.key", "testString"); + expect(call[0]).toHaveProperty("info.path.prev", undefined); + }); + + it('receives the parent when there is one', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + queryString: '{ testObject { testString } }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + // It is called 1st for `testObject` and then 2nd for `testString`. + expect(willResolveField).toHaveBeenCalledTimes(2); + const [firstCall, secondCall] = willResolveField.mock.calls; + expect(firstCall[0]).toHaveProperty("source", undefined); + expect(firstCall[0]).toHaveProperty("info.path.key", "testObject"); + expect(firstCall[0]).toHaveProperty("info.path.prev", undefined); + + expect(secondCall[0]).toHaveProperty('source', { + testString: 'a very test string', + }); + expect(secondCall[0]).toHaveProperty("info.path.key", "testString"); + expect(secondCall[0]).toHaveProperty('info.path.prev', { + key: 'testObject', + prev: undefined, + }); + }); + + it('receives context', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + context: { ourSpecialContext: true }, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(willResolveField.mock.calls[0][0]).toHaveProperty("context", + expect.objectContaining({ ourSpecialContext: true }), + ); + }); + + it('receives arguments', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + queryString: '{ testArgumentValue(base: 99) }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(willResolveField.mock.calls[0][0]) + .toHaveProperty("args.base", 99); + }); + }); + + it('calls the end handler', async () => { + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(); + const willResolveField = jest.fn(() => didResolveField); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(didResolveField).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('calls the end handler for each field being resolved', async () => { + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(); + const willResolveField = jest.fn(() => didResolveField); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString again: testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(2); + expect(didResolveField).toHaveBeenCalledTimes(2); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('uses the custom "fieldResolver" when defined', async () => { + const schemaWithResolver = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + resolve() { + return "using schema-defined resolver"; + }, + }, + } + }) + }); + + const schemaWithoutResolver = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + }, + } + }) + }); + + const differentFieldResolver = () => "I'm diffrnt, ya, I'm diffrnt."; + + const queryString = `{ testString } `; + + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(); + const willResolveField = jest.fn(() => didResolveField); + + const plugins: ApolloServerPlugin[] = [ + { + requestDidStart: () => ({ + executionDidStart: () => ({ + willResolveField, + }), + }) + }, + ]; + + const resultFromSchemaWithResolver = await runQuery({ + schema: schemaWithResolver, + queryString, + plugins, + request: new MockReq(), + fieldResolver: differentFieldResolver, + }); + + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(didResolveField).toHaveBeenCalledTimes(1); + + expect(resultFromSchemaWithResolver.data).toEqual({ + testString: "using schema-defined resolver" + }); + + const resultFromSchemaWithoutResolver = await runQuery({ + schema: schemaWithoutResolver, + queryString, + plugins, + request: new MockReq(), + fieldResolver: differentFieldResolver, + }); + + expect(willResolveField).toHaveBeenCalledTimes(2); + expect(didResolveField).toHaveBeenCalledTimes(2); + + expect(resultFromSchemaWithoutResolver.data).toEqual({ + testString: "I'm diffrnt, ya, I'm diffrnt." + }); + }); + }); + }); + + describe('didEncounterErrors', () => { const didEncounterErrors = jest.fn(); const plugins: ApolloServerPlugin[] = [ @@ -569,6 +1123,109 @@ describe('runQuery', () => { expect(didEncounterErrors).not.toBeCalled(); }); }); + + describe("ordering", () => { + it('calls hooks in the expected order', async () => { + const callOrder: string[] = []; + let stopAwaiting: Function; + const toBeAwaited = new Promise(resolve => stopAwaiting = resolve); + + const parsingDidEnd: GraphQLRequestListenerParsingDidEnd = + jest.fn(() => callOrder.push('parsingDidEnd')); + const parsingDidStart: GraphQLRequestListener['parsingDidStart'] = + jest.fn(() => { + callOrder.push('parsingDidStart'); + return parsingDidEnd; + }); + + const validationDidEnd: GraphQLRequestListenerValidationDidEnd = + jest.fn(() => callOrder.push('validationDidEnd')); + const validationDidStart: GraphQLRequestListener['validationDidStart'] = + jest.fn(() => { + callOrder.push('validationDidStart'); + return validationDidEnd; + }); + + const didResolveSource: GraphQLRequestListener['didResolveSource'] = + jest.fn(() => { callOrder.push('didResolveSource') }); + + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(() => callOrder.push("didResolveField")); + + const willResolveField = jest.fn(() => { + callOrder.push("willResolveField"); + return didResolveField; + }); + + const executionDidEnd: GraphQLRequestListenerExecutionDidEnd = + jest.fn(() => callOrder.push('executionDidEnd')); + + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => { + callOrder.push("executionDidStart"); + return { willResolveField, executionDidEnd }; + }, + ); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + async resolve() { + callOrder.push("beforeAwaiting"); + await toBeAwaited; + callOrder.push("afterAwaiting"); + return "it works"; + }, + }, + } + }) + }); + + Promise.resolve().then(() => stopAwaiting()); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + parsingDidStart, + validationDidStart, + didResolveSource, + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(parsingDidStart).toHaveBeenCalledTimes(1); + expect(parsingDidEnd).toHaveBeenCalledTimes(1); + expect(validationDidStart).toHaveBeenCalledTimes(1); + expect(validationDidEnd).toHaveBeenCalledTimes(1); + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(didResolveField).toHaveBeenCalledTimes(1); + expect(callOrder).toStrictEqual([ + "didResolveSource", + "parsingDidStart", + "parsingDidEnd", + "validationDidStart", + "validationDidEnd", + "executionDidStart", + "willResolveField", + "beforeAwaiting", + "afterAwaiting", + "didResolveField", + "executionDidEnd", + ]); + }); + }) }); describe('parsing and validation cache', () => { diff --git a/packages/apollo-server-core/src/graphqlOptions.ts b/packages/apollo-server-core/src/graphqlOptions.ts index e6da70d37b0..b4c6f1a7b86 100644 --- a/packages/apollo-server-core/src/graphqlOptions.ts +++ b/packages/apollo-server-core/src/graphqlOptions.ts @@ -18,6 +18,7 @@ import { GraphQLResponse, GraphQLRequestContext, Logger, + SchemaHash, } from 'apollo-server-types'; /* @@ -42,6 +43,7 @@ export interface GraphQLServerOptions< TRootValue = any > { schema: GraphQLSchema; + schemaHash: SchemaHash; logger?: Logger; formatError?: (error: GraphQLError) => GraphQLFormattedError; rootValue?: ((parsedQuery: DocumentNode) => TRootValue) | TRootValue; diff --git a/packages/apollo-server-core/src/requestPipeline.ts b/packages/apollo-server-core/src/requestPipeline.ts index 07a640c1924..d3588f04e1a 100644 --- a/packages/apollo-server-core/src/requestPipeline.ts +++ b/packages/apollo-server-core/src/requestPipeline.ts @@ -17,12 +17,12 @@ import { enableGraphQLExtensions, } from 'graphql-extensions'; import { DataSource } from 'apollo-datasource'; -import { PersistedQueryOptions } from '.'; +import { PersistedQueryOptions } from './graphqlOptions'; import { - CacheControlExtension, - CacheControlExtensionOptions, -} from 'apollo-cache-control'; -import { TracingExtension } from 'apollo-tracing'; + symbolExecutionDispatcherWillResolveField, + enablePluginsForSchemaResolvers, + symbolUserFieldResolver, +} from "./utils/schemaInstrumentation" import { ApolloError, fromGraphQLError, @@ -45,6 +45,7 @@ import { import { ApolloServerPlugin, GraphQLRequestListener, + GraphQLRequestContextDidResolveSource, GraphQLRequestContextExecutionDidStart, GraphQLRequestContextResponseForOperation, GraphQLRequestContextDidResolveOperation, @@ -52,6 +53,7 @@ import { GraphQLRequestContextValidationDidStart, GraphQLRequestContextWillSendResponse, GraphQLRequestContextDidEncounterErrors, + GraphQLRequestExecutionListener, } from 'apollo-server-plugin-base'; import { Dispatcher } from './utils/dispatcher'; @@ -91,9 +93,7 @@ export interface GraphQLRequestPipelineConfig { dataSources?: () => DataSources; extensions?: Array<() => GraphQLExtension>; - tracing?: boolean; persistedQueries?: PersistedQueryOptions; - cacheControl?: CacheControlExtensionOptions; formatError?: (error: GraphQLError) => GraphQLFormattedError; formatResponse?: ( @@ -113,6 +113,14 @@ export type DataSources = { type Mutable = { -readonly [P in keyof T]: T[P] }; +/** + * We attach this symbol to the constructor of extensions to mark that we've + * already warned about the deprecation of the `graphql-extensions` API for that + * particular definition. + */ +const symbolExtensionDeprecationDone = + Symbol("apolloServerExtensionDeprecationDone"); + export async function processGraphQLRequest( config: GraphQLRequestPipelineConfig, requestContext: Mutable>, @@ -122,19 +130,22 @@ export async function processGraphQLRequest( // all of our own machinery will certainly set it now. const logger = requestContext.logger || console; - let cacheControlExtension: CacheControlExtension | undefined; + // If request context's `metrics` already exists, preserve it, but _ensure_ it + // exists there and shorthand it for use throughout this function. As of this + // comment, the sole known case where `metrics` already exists is when the + // `captureTraces` property is present and set to the result of the boolean + // `reporting` option on the legacy (V1) server options, here: + // https://git.io/Jfmsb. I suspect this disappears when this is the direct + // entry into request processing, rather than through, e.g. `runHttpQuery`. + const metrics = requestContext.metrics = + requestContext.metrics || Object.create(null); + const extensionStack = initializeExtensionStack(); (requestContext.context as any)._extensionStack = extensionStack; const dispatcher = initializeRequestListenerDispatcher(); - await initializeDataSources(); - const metrics = requestContext.metrics || Object.create(null); - if (!requestContext.metrics) { - requestContext.metrics = metrics; - } - const request = requestContext.request; let { query, extensions } = request; @@ -149,8 +160,16 @@ export async function processGraphQLRequest( // It looks like we've received a persisted query. Check if we // support them. if (!config.persistedQueries || !config.persistedQueries.cache) { + // We are returning to `runHttpQuery` to preserve legacy behavior while + // still delivering observability to the `didEncounterErrors` hook. + // This particular error will _not_ trigger `willSendResponse`. + // See comment on `emitErrorAndThrow` for more details. return await emitErrorAndThrow(new PersistedQueryNotSupportedError()); } else if (extensions.persistedQuery.version !== 1) { + // We are returning to `runHttpQuery` to preserve legacy behavior while + // still delivering observability to the `didEncounterErrors` hook. + // This particular error will _not_ trigger `willSendResponse`. + // See comment on `emitErrorAndThrow` for more details. return await emitErrorAndThrow( new InvalidGraphQLRequestError('Unsupported persisted query version')); } @@ -177,12 +196,20 @@ export async function processGraphQLRequest( if (query) { metrics.persistedQueryHit = true; } else { + // We are returning to `runHttpQuery` to preserve legacy behavior while + // still delivering observability to the `didEncounterErrors` hook. + // This particular error will _not_ trigger `willSendResponse`. + // See comment on `emitErrorAndThrow` for more details. return await emitErrorAndThrow(new PersistedQueryNotFoundError()); } } else { const computedQueryHash = computeQueryHash(query); if (queryHash !== computedQueryHash) { + // We are returning to `runHttpQuery` to preserve legacy behavior while + // still delivering observability to the `didEncounterErrors` hook. + // This particular error will _not_ trigger `willSendResponse`. + // See comment on `emitErrorAndThrow` for more details. return await emitErrorAndThrow( new InvalidGraphQLRequestError('provided sha does not match query')); } @@ -198,6 +225,10 @@ export async function processGraphQLRequest( // now, but this should be replaced with the new operation ID algorithm. queryHash = computeQueryHash(query); } else { + // We are returning to `runHttpQuery` to preserve legacy behavior + // while still delivering observability to the `didEncounterErrors` hook. + // This particular error will _not_ trigger `willSendResponse`. + // See comment on `emitErrorAndThrow` for more details. return await emitErrorAndThrow( new InvalidGraphQLRequestError('Must provide query string.')); } @@ -205,6 +236,16 @@ export async function processGraphQLRequest( requestContext.queryHash = queryHash; requestContext.source = query; + // Let the plugins know that we now have a STRING of what we hope will + // parse and validate into a document we can execute on. Unless we have + // retrieved this from our APQ cache, there's no guarantee that it is + // syntactically correct, so this string should not be trusted as a valid + // document until after it's parsed and validated. + await dispatcher.invokeHookAsync( + 'didResolveSource', + requestContext as GraphQLRequestContextDidResolveSource, + ); + const requestDidEnd = extensionStack.requestDidStart({ request: request.http!, queryString: request.query, @@ -338,6 +379,9 @@ export async function processGraphQLRequest( // an error) and not actually write, we'll write to the cache if it was // determined earlier in the request pipeline that we should do so. if (metrics.persistedQueryRegister && persistedQueryCache) { + // While it shouldn't normally be necessary to wrap this `Promise` in a + // `Promise.resolve` invocation, it seems that the underlying cache store + // is returning a non-native `Promise` (e.g. Bluebird, etc.). Promise.resolve( persistedQueryCache.set( queryHash, @@ -357,11 +401,55 @@ export async function processGraphQLRequest( requestContext as GraphQLRequestContextResponseForOperation, ); if (response == null) { - const executionDidEnd = await dispatcher.invokeDidStartHook( + // This execution dispatcher code is duplicated in `pluginTestHarness` + // right now. + + const executionListeners: GraphQLRequestExecutionListener[] = []; + dispatcher.invokeHookSync( 'executionDidStart', requestContext as GraphQLRequestContextExecutionDidStart, + ).forEach(executionListener => { + if (typeof executionListener === 'function') { + executionListeners.push({ + executionDidEnd: executionListener, + }); + } else if (typeof executionListener === 'object') { + executionListeners.push(executionListener); + } + }); + + const executionDispatcher = new Dispatcher(executionListeners); + + // Create a callback that will trigger the execution dispatcher's + // `willResolveField` hook. We will attach this to the context on a + // symbol so it can be invoked by our `wrapField` method during execution. + const invokeWillResolveField: GraphQLRequestExecutionListener< + TContext + >['willResolveField'] = (...args) => + executionDispatcher.invokeDidStartHook('willResolveField', ...args); + + Object.defineProperty( + requestContext.context, + symbolExecutionDispatcherWillResolveField, + { value: invokeWillResolveField } ); + // If the user has provided a custom field resolver, we will attach + // it to the context so we can still invoke it after we've wrapped the + // fields with `wrapField` within `enablePluginsForSchemaResolvers` of + // the `schemaInstrumentation` module. + if (config.fieldResolver) { + Object.defineProperty( + requestContext.context, + symbolUserFieldResolver, + { value: config.fieldResolver } + ); + } + + // If the schema is already enabled, this is a no-op. Otherwise, the + // schema will be augmented so it is able to invoke willResolveField. + enablePluginsForSchemaResolvers(config.schema); + try { const result = await execute( requestContext as GraphQLRequestContextExecutionDidStart, @@ -376,27 +464,13 @@ export async function processGraphQLRequest( errors: result.errors ? formatErrors(result.errors) : undefined, }; - executionDidEnd(); + executionDispatcher.reverseInvokeHookSync("executionDidEnd"); } catch (executionError) { - executionDidEnd(executionError); + executionDispatcher.reverseInvokeHookSync("executionDidEnd", executionError); return await sendErrorResponse(executionError); } } - if (cacheControlExtension) { - if (requestContext.overallCachePolicy) { - // If we read this response from a cache and it already has its own - // policy, teach that to cacheControlExtension so that it'll use the - // saved policy for HTTP headers. (If cacheControlExtension was a - // plugin, it could just read from the requestContext, but it isn't.) - cacheControlExtension.overrideOverallCachePolicy( - requestContext.overallCachePolicy, - ); - } else { - requestContext.overallCachePolicy = cacheControlExtension.computeOverallCachePolicy(); - } - } - const formattedExtensions = extensionStack.format(); if (Object.keys(formattedExtensions).length > 0) { response.extensions = formattedExtensions; @@ -505,7 +579,15 @@ export async function processGraphQLRequest( } /** - * Report an error via `didEncounterErrors` and then `throw` it. + * HEREIN LIE LEGACY COMPATIBILITY + * + * DO NOT PERPETUATE THE USE OF THIS METHOD IN NEWLY INTRODUCED CODE. + * + * Report an error via `didEncounterErrors` and then `throw` it again, + * ENTIRELY BYPASSING the rest of the request pipeline and returning + * control to `runHttpQuery.ts`. + * + * Any number of other life-cycle events may not be invoked in this case. * * Prior to the introduction of this function, some errors were being thrown * within the request pipeline and going directly to handling within @@ -569,7 +651,7 @@ export async function processGraphQLRequest( } function initializeRequestListenerDispatcher(): Dispatcher< - GraphQLRequestListener + GraphQLRequestListener > { const requestListeners: GraphQLRequestListener[] = []; if (config.plugins) { @@ -591,14 +673,43 @@ export async function processGraphQLRequest( // objects. const extensions = config.extensions ? config.extensions.map(f => f()) : []; - if (config.tracing) { - extensions.push(new TracingExtension()); - } + // Warn about usage of (deprecated) `graphql-extensions` implementations. + // Since extensions are often provided as factory functions which + // instantiate an extension on each request, we'll attach a symbol to the + // constructor after we've warned to ensure that we don't do it on each + // request. Another option here might be to keep a `Map` of constructor + // instances within this module, but I hope this will do the trick. + const hasOwn = Object.prototype.hasOwnProperty; + extensions.forEach((extension) => { + // Using `hasOwn` just in case there is a user-land `hasOwnProperty` + // defined on the `constructor` object. + if ( + !extension.constructor || + hasOwn.call(extension.constructor, symbolExtensionDeprecationDone) + ) { + return; + } - if (config.cacheControl) { - cacheControlExtension = new CacheControlExtension(config.cacheControl); - extensions.push(cacheControlExtension); - } + Object.defineProperty( + extension.constructor, + symbolExtensionDeprecationDone, + { value: true } + ); + + const extensionName = extension.constructor.name; + logger.warn( + '[deprecated] ' + + (extensionName + ? 'A "' + extensionName + '" ' + : 'An anonymous extension ') + + 'was defined within the "extensions" configuration for ' + + 'Apollo Server. The API on which this extension is built ' + + '("graphql-extensions") is being deprecated in the next major ' + + 'version of Apollo Server in favor of the new plugin API. See ' + + 'https://go.apollo.dev/s/plugins for the documentation on how ' + + 'these plugins are to be defined and used.', + ); + }); return new GraphQLExtensionStack(extensions); } diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index c229fe87cf7..90053cc2c28 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -17,7 +17,6 @@ import { GraphQLRequestContext, GraphQLResponse, } from './requestPipeline'; -import { CacheControlExtensionOptions } from 'apollo-cache-control'; import { ApolloServerPlugin } from 'apollo-server-plugin-base'; import { WithRequired, GraphQLExecutionResult } from 'apollo-server-types'; @@ -161,6 +160,7 @@ export async function runHttpQuery( const config = { schema: options.schema, + schemaHash: options.schemaHash, logger: options.logger, rootValue: options.rootValue, context: options.context || {}, @@ -173,9 +173,6 @@ export async function runHttpQuery( // cacheControl defaults will also have been set if a boolean argument is // passed in. cache: options.cache!, - cacheControl: options.cacheControl as - | CacheControlExtensionOptions - | undefined, dataSources: options.dataSources, documentStore: options.documentStore, @@ -256,6 +253,8 @@ export async function processHTTPRequest( // exported since perhaps as far back as Apollo Server 1.x. Therefore, // for compatibility reasons, we'll default to `console`. logger: options.logger || console, + schema: options.schema, + schemaHash: options.schemaHash, request, response: { http: { diff --git a/packages/apollo-server-core/src/utils/dispatcher.ts b/packages/apollo-server-core/src/utils/dispatcher.ts index d311eaf055a..122fa9aa408 100644 --- a/packages/apollo-server-core/src/utils/dispatcher.ts +++ b/packages/apollo-server-core/src/utils/dispatcher.ts @@ -1,27 +1,47 @@ -import { GraphQLRequestListener } from "apollo-server-plugin-base"; +import { AnyFunction, AnyFunctionMap } from "apollo-server-types"; -type AnyFunction = (...args: any[]) => any; type Args = F extends (...args: infer A) => any ? A : never; type AsFunction = F extends AnyFunction ? F : never; type UnwrapPromise = T extends Promise ? U : T; type DidEndHook = (...args: TArgs) => void; -export class Dispatcher { +export class Dispatcher { constructor(protected targets: T[]) {} + private callTargets( + targets: T[], + methodName: TMethodName, + ...args: Args + ): ReturnType>[] { + return targets.map(target => { + const method = target[methodName]; + if (method && typeof method === 'function') { + return method.apply(target, args); + } + }); + } + public async invokeHookAsync( methodName: TMethodName, ...args: Args - ): Promise>>[]> { + ): Promise>[]> { return await Promise.all( - this.targets.map(target => { - const method = target[methodName]; - if (method && typeof method === 'function') { - return method.apply(target, args); - } - }), - ); + this.callTargets(this.targets, methodName, ...args)); + } + + public invokeHookSync( + methodName: TMethodName, + ...args: Args + ): ReturnType>[] { + return this.callTargets(this.targets, methodName, ...args); + } + + public reverseInvokeHookSync( + methodName: TMethodName, + ...args: Args + ): ReturnType>[] { + return this.callTargets(this.targets.reverse(), methodName, ...args); } public async invokeHooksUntilNonNull( diff --git a/packages/apollo-server-core/src/utils/pluginTestHarness.ts b/packages/apollo-server-core/src/utils/pluginTestHarness.ts new file mode 100644 index 00000000000..9af86a5326a --- /dev/null +++ b/packages/apollo-server-core/src/utils/pluginTestHarness.ts @@ -0,0 +1,186 @@ +import { + WithRequired, + GraphQLRequest, + GraphQLRequestContextExecutionDidStart, + GraphQLResponse, + ValueOrPromise, + GraphQLRequestContextWillSendResponse, + GraphQLRequestContext, + Logger, +} from 'apollo-server-types'; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql/type'; +import { CacheHint } from 'apollo-cache-control'; +import { + enablePluginsForSchemaResolvers, + symbolExecutionDispatcherWillResolveField, +} from './schemaInstrumentation'; +import { + ApolloServerPlugin, + GraphQLRequestExecutionListener, +} from 'apollo-server-plugin-base'; +import { InMemoryLRUCache } from 'apollo-server-caching'; +import { Dispatcher } from './dispatcher'; +import { generateSchemaHash } from "./schemaHash"; + +// This test harness guarantees the presence of `query`. +type IPluginTestHarnessGraphqlRequest = WithRequired; +type IPluginTestHarnessExecutionDidStart = + GraphQLRequestContextExecutionDidStart & { + request: IPluginTestHarnessGraphqlRequest, + }; + +export default async function pluginTestHarness({ + pluginInstance, + schema, + logger, + graphqlRequest, + overallCachePolicy, + executor, + context = Object.create(null) +}: { + /** + * An instance of the plugin to test. + */ + pluginInstance: ApolloServerPlugin, + + /** + * The optional schema that will be received by the executor. If not + * specified, a simple default schema will be created. In either case, + * the schema will be mutated by wrapping the resolvers with the + * `willResolveField` instrumentation that will allow it to respond to + * that lifecycle hook's implementations plugins. + */ + schema?: GraphQLSchema; + + /** + * An optional logger (Defaults to `console`) + */ + logger?: Logger; + + /** + * The `GraphQLRequest` which will be received by the `executor`. The + * `query` is required, and this doesn't support anything more exotic, + * like automated persisted queries (APQ). + */ + graphqlRequest: IPluginTestHarnessGraphqlRequest; + + /** + * Overall cache control policy. + */ + overallCachePolicy?: Required; + + /** + * This method will be executed to retrieve the response. + */ + executor: ( + requestContext: IPluginTestHarnessExecutionDidStart, + ) => ValueOrPromise; + + /** + * (optional) To provide a user context, if necessary. + */ + context?: TContext; +}): Promise> { + if (!schema) { + schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'RootQueryType', + fields: { + hello: { + type: GraphQLString, + resolve() { + return 'hello world'; + } + } + } + }) + }); + } + + const schemaHash = generateSchemaHash(schema); + if (typeof pluginInstance.serverWillStart === 'function') { + pluginInstance.serverWillStart({ + logger: logger || console, + schema, + schemaHash, + engine: {}, + }); + } + + + const requestContext: GraphQLRequestContext = { + logger: logger || console, + schema, + schemaHash: generateSchemaHash(schema), + request: graphqlRequest, + metrics: Object.create(null), + source: graphqlRequest.query, + cache: new InMemoryLRUCache(), + context, + }; + + requestContext.overallCachePolicy = overallCachePolicy; + + if (typeof pluginInstance.requestDidStart !== "function") { + throw new Error("This test harness expects this to be defined."); + } + + const listener = pluginInstance.requestDidStart(requestContext); + + const dispatcher = new Dispatcher(listener ? [listener] : []); + + const executionListeners: GraphQLRequestExecutionListener[] = []; + + // This execution dispatcher logic is duplicated in the request pipeline + // right now. + dispatcher.invokeHookSync( + 'executionDidStart', + requestContext as GraphQLRequestContextExecutionDidStart, + ).forEach(executionListener => { + if (typeof executionListener === 'function') { + executionListeners.push({ + executionDidEnd: executionListener, + }); + } else if (typeof executionListener === 'object') { + executionListeners.push(executionListener); + } + }); + + const executionDispatcher = new Dispatcher(executionListeners); + + // Create a callback that will trigger the execution dispatcher's + // `willResolveField` hook. We will attach this to the context on a + // symbol so it can be invoked by our `wrapField` method during execution. + const invokeWillResolveField: GraphQLRequestExecutionListener< + TContext + >['willResolveField'] = (...args) => + executionDispatcher.invokeDidStartHook('willResolveField', ...args); + + Object.defineProperty( + requestContext.context, + symbolExecutionDispatcherWillResolveField, + { value: invokeWillResolveField } + ); + + // If the schema is already enabled, this is a no-op. Otherwise, the + // schema will be augmented so it is able to invoke willResolveField. + enablePluginsForSchemaResolvers(schema); + + try { + // `response` is readonly, so we'll cast to `any` to assign to it. + (requestContext.response as any) = await executor( + requestContext as IPluginTestHarnessExecutionDidStart, + ); + executionDispatcher.reverseInvokeHookSync("executionDidEnd"); + + } catch (executionErr) { + executionDispatcher.reverseInvokeHookSync("executionDidEnd", executionErr); + } + + await dispatcher.invokeHookAsync( + "willSendResponse", + requestContext as GraphQLRequestContextWillSendResponse, + ); + + return requestContext as GraphQLRequestContextWillSendResponse; +} diff --git a/packages/apollo-server-core/src/utils/schemaHash.ts b/packages/apollo-server-core/src/utils/schemaHash.ts index 7c30c6e751d..7137b9dac6c 100644 --- a/packages/apollo-server-core/src/utils/schemaHash.ts +++ b/packages/apollo-server-core/src/utils/schemaHash.ts @@ -4,8 +4,9 @@ import { getIntrospectionQuery, IntrospectionSchema } from 'graphql/utilities'; import stableStringify from 'fast-json-stable-stringify'; import { GraphQLSchema } from 'graphql/type'; import createSHA from './createSHA'; +import { SchemaHash } from "apollo-server-types"; -export function generateSchemaHash(schema: GraphQLSchema): string { +export function generateSchemaHash(schema: GraphQLSchema): SchemaHash { const introspectionQuery = getIntrospectionQuery(); const documentAST = parse(introspectionQuery); const result = execute(schema, documentAST) as ExecutionResult; @@ -40,5 +41,5 @@ export function generateSchemaHash(schema: GraphQLSchema): string { return createSHA('sha512') .update(stringifiedSchema) - .digest('hex'); + .digest('hex') as SchemaHash; } diff --git a/packages/apollo-server-core/src/utils/schemaInstrumentation.ts b/packages/apollo-server-core/src/utils/schemaInstrumentation.ts new file mode 100644 index 00000000000..e20f94a9311 --- /dev/null +++ b/packages/apollo-server-core/src/utils/schemaInstrumentation.ts @@ -0,0 +1,172 @@ +import { + GraphQLSchema, + GraphQLField, + ResponsePath, + getNamedType, + GraphQLObjectType, + GraphQLFieldResolver, +} from 'graphql/type'; +import { defaultFieldResolver } from "graphql/execution"; +import { FieldNode } from "graphql/language"; +import { GraphQLRequestExecutionListener } from "apollo-server-plugin-base"; +import { GraphQLObjectResolver } from "@apollographql/apollo-tools"; + +export const symbolExecutionDispatcherWillResolveField = + Symbol("apolloServerExecutionDispatcherWillResolveField"); +export const symbolUserFieldResolver = + Symbol("apolloServerUserFieldResolver"); +export const symbolPluginsEnabled = Symbol("apolloServerPluginsEnabled"); + +export function enablePluginsForSchemaResolvers( + schema: GraphQLSchema & { [symbolPluginsEnabled]?: boolean }, +) { + if (schema[symbolPluginsEnabled]) { + return schema; + } + Object.defineProperty(schema, symbolPluginsEnabled, { + value: true, + }); + + forEachField(schema, wrapField); + + return schema; +} + +function wrapField(field: GraphQLField): void { + const originalFieldResolve = field.resolve; + + field.resolve = (source, args, context, info) => { + // This is a bit of a hack, but since `ResponsePath` is a linked list, + // a new object gets created every time a path segment is added. + // So we can use that to share our `whenObjectResolved` promise across + // all field resolvers for the same object. + const parentPath = info.path.prev as ResponsePath & { + __fields?: Record>; + __whenObjectResolved?: Promise; + }; + + const willResolveField = + context?.[symbolExecutionDispatcherWillResolveField] as + | GraphQLRequestExecutionListener['willResolveField'] + | undefined; + + const userFieldResolver = + context?.[symbolUserFieldResolver] as + | GraphQLFieldResolver + | undefined; + + // The technique for implementing a "did resolve field" is accomplished by + // returning a function from the `willResolveField` handler. While there + // may be several callbacks, depending on the number of plugins which have + // implemented a `willResolveField` hook, this hook will call them all + // as dictated by the dispatcher. We will call this when object + // resolution is complete. + const didResolveField = + typeof willResolveField === 'function' && + willResolveField({ source, args, context, info }); + + const resolveObject: GraphQLObjectResolver< + any, + any + > = (info.parentType as any).resolveObject; + + let whenObjectResolved: Promise | undefined; + + if (parentPath && resolveObject) { + if (!parentPath.__fields) { + parentPath.__fields = {}; + } + + parentPath.__fields[info.fieldName] = info.fieldNodes; + + whenObjectResolved = parentPath.__whenObjectResolved; + if (!whenObjectResolved) { + // Use `Promise.resolve().then()` to delay executing + // `resolveObject()` so we can collect all the fields first. + whenObjectResolved = Promise.resolve().then(() => { + return resolveObject(source, parentPath.__fields!, context, info); + }); + parentPath.__whenObjectResolved = whenObjectResolved; + } + } + + const fieldResolver = + originalFieldResolve || userFieldResolver || defaultFieldResolver; + + try { + let result: any; + if (whenObjectResolved) { + result = whenObjectResolved.then((resolvedObject: any) => { + return fieldResolver(resolvedObject, args, context, info); + }); + } else { + result = fieldResolver(source, args, context, info); + } + + // Call the stack's handlers either immediately (if result is not a + // Promise) or once the Promise is done. Then return that same + // maybe-Promise value. + if (typeof didResolveField === "function") { + whenResultIsFinished(result, didResolveField); + } + return result; + } catch (error) { + // Normally it's a bad sign to see an error both handled and + // re-thrown. But it is useful to allow extensions to track errors while + // still handling them in the normal GraphQL way. + if (typeof didResolveField === "function") { + didResolveField(error); + } + throw error; + } + };; +} + +function isPromise(x: any): boolean { + return x && typeof x.then === 'function'; +} + +// Given result (which may be a Promise or an array some of whose elements are +// promises) Promises, set up 'callback' to be invoked when result is fully +// resolved. +export function whenResultIsFinished( + result: any, + callback: (err: Error | null, result?: any) => void, +) { + if (isPromise(result)) { + result.then((r: any) => callback(null, r), (err: Error) => callback(err)); + } else if (Array.isArray(result)) { + if (result.some(isPromise)) { + Promise.all(result).then( + (r: any) => callback(null, r), + (err: Error) => callback(err), + ); + } else { + callback(null, result); + } + } else { + callback(null, result); + } +} + +function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { + const typeMap = schema.getTypeMap(); + Object.entries(typeMap).forEach(([typeName, type]) => { + + if ( + !getNamedType(type).name.startsWith('__') && + type instanceof GraphQLObjectType + ) { + const fields = type.getFields(); + Object.entries(fields).forEach(([fieldName, field]) => { + fn(field, typeName, fieldName); + }); + } + }); +} + +type FieldIteratorFn = ( + fieldDef: GraphQLField, + typeName: string, + fieldName: string, +) => void; diff --git a/packages/apollo-server-env/package.json b/packages/apollo-server-env/package.json index a09556b2c5f..0a6028862a6 100644 --- a/packages/apollo-server-env/package.json +++ b/packages/apollo-server-env/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-env", - "version": "2.4.4-alpha.0", + "version": "2.4.4", "author": "opensource@apollographql.com", "license": "MIT", "repository": { diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index d7d902f7796..f561839d771 100644 --- a/packages/apollo-server-express/package.json +++ b/packages/apollo-server-express/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-express", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Express and Connect", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-fastify/package.json b/packages/apollo-server-fastify/package.json index dff9ad392ac..a3faffc9179 100644 --- a/packages/apollo-server-fastify/package.json +++ b/packages/apollo-server-fastify/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-fastify", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Fastify", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-hapi/package.json b/packages/apollo-server-hapi/package.json index d5452701029..e736ddaa328 100644 --- a/packages/apollo-server-hapi/package.json +++ b/packages/apollo-server-hapi/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-hapi", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Hapi", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-integration-testsuite/package.json b/packages/apollo-server-integration-testsuite/package.json index bd38bf50708..fc00b73707a 100644 --- a/packages/apollo-server-integration-testsuite/package.json +++ b/packages/apollo-server-integration-testsuite/package.json @@ -1,7 +1,7 @@ { "name": "apollo-server-integration-testsuite", "private": true, - "version": "2.13.1", + "version": "2.14.0", "description": "Apollo Server Integrations testsuite", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts index 521c52bcabd..85257054458 100644 --- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -852,7 +852,7 @@ export function testApolloServer( public engineOptions(): Partial> { return { - endpointUrl: this.getUrl(), + tracesEndpointUrl: this.getUrl(), }; } @@ -2170,7 +2170,7 @@ export function testApolloServer( resolvers: { Query: { something: () => 'hello' } }, engine: { apiKey: 'service:my-app:secret', - endpointUrl: fakeEngineUrl, + tracesEndpointUrl: fakeEngineUrl, reportIntervalMs: 1, maxAttempts: 3, requestAgent, diff --git a/packages/apollo-server-integration-testsuite/src/index.ts b/packages/apollo-server-integration-testsuite/src/index.ts index a5dd026108f..e39cb84d036 100644 --- a/packages/apollo-server-integration-testsuite/src/index.ts +++ b/packages/apollo-server-integration-testsuite/src/index.ts @@ -18,7 +18,12 @@ import { import request from 'supertest'; -import { GraphQLOptions, Config } from 'apollo-server-core'; +import { + GraphQLOptions, + Config, + PersistedQueryOptions, + KeyValueCache, +} from 'apollo-server-core'; import gql from 'graphql-tag'; import { ValueOrPromise } from 'apollo-server-types'; import { GraphQLRequestListener } from "apollo-server-plugin-base"; @@ -1221,12 +1226,7 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }, }; - let didEncounterErrors: jest.Mock< - ReturnType, - Parameters - >; - - function createMockCache() { + function createMockCache(): KeyValueCache { const map = new Map(); return { set: jest.fn(async (key, val) => { @@ -1237,37 +1237,48 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }; } - beforeEach(async () => { - didEncounterErrors = jest.fn(); - const cache = createMockCache(); - app = await createApp({ + let didEncounterErrors: jest.Mock< + ReturnType, + Parameters + >; + + let didResolveSource: jest.Mock< + ReturnType, + Parameters + >; + + function createApqApp(apqOptions: PersistedQueryOptions = {}) { + return createApp({ graphqlOptions: { schema, plugins: [ { requestDidStart() { - return { didEncounterErrors }; + return { + didResolveSource, + didEncounterErrors, + }; } } ], persistedQueries: { cache, + ...apqOptions, }, }, }); + } + + let cache: KeyValueCache; + + beforeEach(async () => { + cache = createMockCache(); + didResolveSource = jest.fn(); + didEncounterErrors = jest.fn(); }); it('when ttlSeconds is set, passes ttl to the apq cache set call', async () => { - const cache = createMockCache(); - app = await createApp({ - graphqlOptions: { - schema, - persistedQueries: { - cache: cache, - ttl: 900, - }, - }, - }); + app = await createApqApp({ ttl: 900 }); await request(app) .post('/graphql') @@ -1278,24 +1289,18 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { expect(cache.set).toHaveBeenCalledWith( expect.stringMatching(/^apq:/), - '{testString}', + query, expect.objectContaining({ ttl: 900, }), ); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); }); it('when ttlSeconds is unset, ttl is not passed to apq cache', async () => { - const cache = createMockCache(); - app = await createApp({ - graphqlOptions: { - schema, - persistedQueries: { - cache: cache, - }, - }, - }); + app = await createApqApp(); await request(app) .post('/graphql') @@ -1311,10 +1316,14 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { ttl: 900, }), ); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); } ); it('errors when version is not specified', async () => { + app = await createApqApp(); + const result = await request(app) .get('/graphql') .query({ @@ -1346,6 +1355,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('errors when version is unsupported', async () => { + app = await createApqApp(); + const result = await request(app) .get('/graphql') .query({ @@ -1378,6 +1389,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('errors when hash is mismatched', async () => { + app = await createApqApp(); + const result = await request(app) .get('/graphql') .query({ @@ -1407,9 +1420,13 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { })]), }), ); + + expect(didResolveSource).not.toHaveBeenCalled(); }); it('returns PersistedQueryNotFound on the first try', async () => { + app = await createApqApp(); + const result = await request(app) .post('/graphql') .send({ @@ -1430,8 +1447,12 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { ]), }), ); + + expect(didResolveSource).not.toHaveBeenCalled(); }); it('returns result on the second try', async () => { + app = await createApqApp(); + await request(app) .post('/graphql') .send({ @@ -1448,6 +1469,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }), ); + expect(didResolveSource).not.toHaveBeenCalled(); + const result = await request(app) .post('/graphql') .send({ @@ -1460,11 +1483,16 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { // asserted above. expect(didEncounterErrors).toHaveBeenCalledTimes(1); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); + expect(result.body.data).toEqual({ testString: 'it works' }); expect(result.body.errors).toBeUndefined(); }); it('returns with batched persisted queries', async () => { + app = await createApqApp(); + const errors = await request(app) .post('/graphql') .send([ @@ -1510,11 +1538,16 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('returns result on the persisted query', async () => { + app = await createApqApp(); + await request(app) .post('/graphql') .send({ extensions, }); + + expect(didResolveSource).not.toHaveBeenCalled(); + await request(app) .post('/graphql') .send({ @@ -1527,11 +1560,16 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { extensions, }); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); + expect(result.body.data).toEqual({ testString: 'it works' }); expect(result.body.errors).toBeUndefined(); }); it('returns error when hash does not match', async () => { + app = await createApqApp(); + const response = await request(app) .post('/graphql') .send({ @@ -1546,9 +1584,12 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); expect(response.status).toEqual(400); expect(response.error.text).toMatch(/does not match query/); + expect(didResolveSource).not.toHaveBeenCalled(); }); it('returns correct result using get request', async () => { + app = await createApqApp(); + await request(app) .post('/graphql') .send({ @@ -1561,6 +1602,9 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { extensions: JSON.stringify(extensions), }); expect(result.body.data).toEqual({ testString: 'it works' }); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); + }); }); }); diff --git a/packages/apollo-server-koa/package.json b/packages/apollo-server-koa/package.json index 0c335931c08..30acbc55cf3 100644 --- a/packages/apollo-server-koa/package.json +++ b/packages/apollo-server-koa/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-koa", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Koa", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-lambda/package.json b/packages/apollo-server-lambda/package.json index f1280b4acf6..888623d918b 100644 --- a/packages/apollo-server-lambda/package.json +++ b/packages/apollo-server-lambda/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-lambda", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for AWS Lambda", "keywords": [ "GraphQL", diff --git a/packages/apollo-server-micro/package.json b/packages/apollo-server-micro/package.json index 716036e45ac..f7e5f6dbd85 100644 --- a/packages/apollo-server-micro/package.json +++ b/packages/apollo-server-micro/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-micro", - "version": "2.13.1", + "version": "2.14.0", "description": "Production-ready Node.js GraphQL server for Micro", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-plugin-base/package.json b/packages/apollo-server-plugin-base/package.json index ebb16d406bc..9d71b829839 100644 --- a/packages/apollo-server-plugin-base/package.json +++ b/packages/apollo-server-plugin-base/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-plugin-base", - "version": "0.8.1-alpha.0", + "version": "0.9.0", "description": "Apollo Server plugin base classes", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-plugin-base/src/index.ts b/packages/apollo-server-plugin-base/src/index.ts index 6d89ea815cd..a77d6556c16 100644 --- a/packages/apollo-server-plugin-base/src/index.ts +++ b/packages/apollo-server-plugin-base/src/index.ts @@ -1,10 +1,14 @@ import { + AnyFunctionMap, + BaseContext, GraphQLServiceContext, GraphQLRequestContext, GraphQLRequest, GraphQLResponse, ValueOrPromise, WithRequired, + GraphQLFieldResolverParams, + GraphQLRequestContextDidResolveSource, GraphQLRequestContextParsingDidStart, GraphQLRequestContextValidationDidStart, GraphQLRequestContextDidResolveOperation, @@ -23,12 +27,15 @@ import { // In the future, `apollo-server-types` and `apollo-server-plugin-base` will // probably roll into the same "types" package, but that is not today! export { + BaseContext, GraphQLServiceContext, GraphQLRequestContext, GraphQLRequest, GraphQLResponse, ValueOrPromise, WithRequired, + GraphQLFieldResolverParams, + GraphQLRequestContextDidResolveSource, GraphQLRequestContextParsingDidStart, GraphQLRequestContextValidationDidStart, GraphQLRequestContextDidResolveOperation, @@ -38,27 +45,42 @@ export { GraphQLRequestContextWillSendResponse, }; -export interface ApolloServerPlugin = Record> { +// Typings Note! (Fix in AS3?) +// +// There are a number of types in this module which are specifying `void` as +// their return type, despite the fact that we _are_ observing the value. +// It's possible those should instead be `undefined`. For more details, see +// the issue that was logged as a result of this discovery during (unrelated) PR +// review: https://github.com/apollographql/apollo-server/issues/4103 + +export interface ApolloServerPlugin< + TContext extends BaseContext = BaseContext +> { serverWillStart?(service: GraphQLServiceContext): ValueOrPromise; requestDidStart?( requestContext: GraphQLRequestContext, ): GraphQLRequestListener | void; } -export type GraphQLRequestListenerParsingDidEnd = - ((err?: Error) => void) | void; +export type GraphQLRequestListenerParsingDidEnd = (err?: Error) => void; export type GraphQLRequestListenerValidationDidEnd = - ((err?: ReadonlyArray) => void) | void; -export type GraphQLRequestListenerExecutionDidEnd = - ((err?: Error) => void) | void; + ((err?: ReadonlyArray) => void); +export type GraphQLRequestListenerExecutionDidEnd = ((err?: Error) => void); +export type GraphQLRequestListenerDidResolveField = + ((error: Error | null, result?: any) => void); -export interface GraphQLRequestListener> { +export interface GraphQLRequestListener< + TContext extends BaseContext = BaseContext +> extends AnyFunctionMap { + didResolveSource?( + requestContext: GraphQLRequestContextDidResolveSource, + ): ValueOrPromise; parsingDidStart?( requestContext: GraphQLRequestContextParsingDidStart, - ): GraphQLRequestListenerParsingDidEnd; + ): GraphQLRequestListenerParsingDidEnd | void; validationDidStart?( requestContext: GraphQLRequestContextValidationDidStart, - ): GraphQLRequestListenerValidationDidEnd; + ): GraphQLRequestListenerValidationDidEnd | void; didResolveOperation?( requestContext: GraphQLRequestContextDidResolveOperation, ): ValueOrPromise; @@ -75,8 +97,20 @@ export interface GraphQLRequestListener> { ): ValueOrPromise; executionDidStart?( requestContext: GraphQLRequestContextExecutionDidStart, - ): GraphQLRequestListenerExecutionDidEnd; + ): + | GraphQLRequestExecutionListener + | GraphQLRequestListenerExecutionDidEnd + | void; willSendResponse?( requestContext: GraphQLRequestContextWillSendResponse, ): ValueOrPromise; } + +export interface GraphQLRequestExecutionListener< + TContext extends BaseContext = BaseContext +> extends AnyFunctionMap { + executionDidEnd?: GraphQLRequestListenerExecutionDidEnd; + willResolveField?( + fieldResolverParams: GraphQLFieldResolverParams + ): GraphQLRequestListenerDidResolveField | void; +} diff --git a/packages/apollo-server-plugin-operation-registry/package.json b/packages/apollo-server-plugin-operation-registry/package.json index bebfa7b9c6a..fbbd9e4a2f5 100644 --- a/packages/apollo-server-plugin-operation-registry/package.json +++ b/packages/apollo-server-plugin-operation-registry/package.json @@ -1,11 +1,10 @@ { "name": "apollo-server-plugin-operation-registry", - "version": "0.3.1", + "version": "0.3.2", "description": "Apollo Server operation registry", "main": "dist/index.js", "types": "dist/index.d.ts", "keywords": [], - "author": "Apollo ", "repository": { "type": "git", diff --git a/packages/apollo-server-plugin-response-cache/package.json b/packages/apollo-server-plugin-response-cache/package.json index bfcfd106111..d17a5f7cbbf 100644 --- a/packages/apollo-server-plugin-response-cache/package.json +++ b/packages/apollo-server-plugin-response-cache/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-plugin-response-cache", - "version": "0.5.1-alpha.0", + "version": "0.5.2", "description": "Apollo Server full query response cache", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-testing/package.json b/packages/apollo-server-testing/package.json index f597a7a05ce..7a79f0b69db 100644 --- a/packages/apollo-server-testing/package.json +++ b/packages/apollo-server-testing/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-testing", - "version": "2.13.1", + "version": "2.14.0", "description": "Test utils for apollo-server", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-types/package.json b/packages/apollo-server-types/package.json index 9008aab1402..f9c05f83b1e 100644 --- a/packages/apollo-server-types/package.json +++ b/packages/apollo-server-types/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-types", - "version": "0.4.1-alpha.0", + "version": "0.5.0", "description": "Apollo Server shared types", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index 40d2639e4ff..3d02d315fd1 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -7,21 +7,54 @@ import { OperationDefinitionNode, DocumentNode, GraphQLError, + GraphQLResolveInfo, } from 'graphql'; // This seems like it could live in this package too. import { KeyValueCache } from 'apollo-server-caching'; import { Trace } from 'apollo-engine-reporting-protobuf'; +export type BaseContext = Record; + export type ValueOrPromise = T | Promise; export type WithRequired = T & Required>; +/** + * It is not recommended to use this `AnyFunction` type further. + * + * This is a legacy type which aims to do what its name suggests (be the type + * for _any_ function) but it should be replaced with something from the + * TypeScript standard lib. It doesn't truly cover "any" function right now, + * and in particular doesn't consider `this`. For now, it has been brought + * here from the Apollo Server `Dispatcher`, where it was first utilized. + */ +export type AnyFunction = (...args: any[]) => any; + +/** + * A map of `AnyFunction`s which are the interface for our plugin API's + * request listeners. (e.g. `GraphQLRequestListener`s). + */ +export type AnyFunctionMap = { [key: string]: AnyFunction | undefined }; + type Mutable = { -readonly [P in keyof T]: T[P] }; + // By default, TypeScript uses structural typing (as opposed to nominal typing) + // Put another way, if it looks like the type and walks like that type, then + // TypeScript lets it be a type. + // + // That's often okay, but it leaves a lot to be desired since a `string` of one + // type can just be passed in as `string` for that type and TypeScript won't + // complain. Flow offers opaque types which solve this, but TypeScript doesn't + // offer this (yet?). This Faux-paque type can be used to gain nominal-esque + // typing, which is incredibly beneficial during re-factors! + type Fauxpaque = K & { __fauxpaque: T }; + + export type SchemaHash = Fauxpaque; + export interface GraphQLServiceContext { logger: Logger; schema: GraphQLSchema; - schemaHash: string; + schemaHash: SchemaHash; engine: { serviceID?: string; apiKeyHash?: string; @@ -65,6 +98,9 @@ export interface GraphQLRequestContext> { logger: Logger; + readonly schema: GraphQLSchema; + readonly schemaHash: SchemaHash; + readonly context: TContext; readonly cache: KeyValueCache; @@ -88,7 +124,7 @@ export interface GraphQLRequestContext> { */ readonly errors?: ReadonlyArray; - readonly metrics?: GraphQLRequestMetrics; + readonly metrics: GraphQLRequestMetrics; debug?: boolean; } @@ -115,12 +151,35 @@ export type Logger = { error(message?: any): void; } -export type GraphQLRequestContextParsingDidStart = +/** + * This is an object form of the parameters received by typical + * `graphql-js` resolvers. The function type is `GraphQLFieldResolver` + * and normally uses positional parameters. In order to facilitate better + * ergonomics in the Apollo Server plugin API, these have been converted to + * named properties on the object using their names from the upstream + * `GraphQLFieldResolver` type signature. Ergonomic wins, in this case, + * include not needing to have three unused variables in scope just because + * there was a need to access the `info` property in a wrapped plugin. + */ +export type GraphQLFieldResolverParams< + TSource, + TContext, + TArgs = { [argName: string]: any } +> = { + source: TSource; + args: TArgs; + context: TContext; + info: GraphQLResolveInfo; +}; + +export type GraphQLRequestContextDidResolveSource = WithRequired, | 'metrics' | 'source' | 'queryHash' >; +export type GraphQLRequestContextParsingDidStart = + GraphQLRequestContextDidResolveSource; export type GraphQLRequestContextValidationDidStart = GraphQLRequestContextParsingDidStart & WithRequired, diff --git a/packages/apollo-server/package.json b/packages/apollo-server/package.json index caa6121936e..738838bc912 100644 --- a/packages/apollo-server/package.json +++ b/packages/apollo-server/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server", - "version": "2.13.1", + "version": "2.14.0", "description": "Production ready GraphQL Server", "author": "opensource@apollographql.com", "main": "dist/index.js", diff --git a/packages/apollo-tracing/README.md b/packages/apollo-tracing/README.md index ffaab016414..f23149a579a 100644 --- a/packages/apollo-tracing/README.md +++ b/packages/apollo-tracing/README.md @@ -12,14 +12,13 @@ This data can be consumed by [Apollo Graph Manager](https://www.apollographql.co Apollo Server includes built-in support for tracing from version 1.1.0 onwards. -The only code change required is to add `tracing: true` to the options passed to the Apollo Server middleware function for your framework of choice. For example, for Express: +The only code change required is to add `tracing: true` to the options passed to the `ApolloServer` constructor options for your integration of choice. For example, for [`apollo-server-express`](https://npm.im/apollo-server-express): ```javascript -app.use('/graphql', bodyParser.json(), graphqlExpress({ +const { ApolloServer } = require('apollo-server-express'); + +const server = new ApolloServer({ schema, - context: {}, tracing: true, -})); +}); ``` - -> If you are using `express-graphql`, we recommend you switch to Apollo Server. Both `express-graphql` and Apollo Server are based on the [`graphql-js`](https://github.com/graphql/graphql-js) reference implementation, and switching should only require changing a few lines of code. diff --git a/packages/apollo-tracing/package.json b/packages/apollo-tracing/package.json index 18dd20f770c..db4bf3abc66 100644 --- a/packages/apollo-tracing/package.json +++ b/packages/apollo-tracing/package.json @@ -1,6 +1,6 @@ { "name": "apollo-tracing", - "version": "0.10.1-alpha.0", + "version": "0.11.0", "description": "Collect and expose trace data for GraphQL requests", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -12,7 +12,7 @@ }, "dependencies": { "apollo-server-env": "file:../apollo-server-env", - "graphql-extensions": "file:../graphql-extensions" + "apollo-server-plugin-base": "file:../apollo-server-plugin-base" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" diff --git a/packages/apollo-tracing/src/index.ts b/packages/apollo-tracing/src/index.ts index 9c31c22ed54..f6ec8a06765 100644 --- a/packages/apollo-tracing/src/index.ts +++ b/packages/apollo-tracing/src/index.ts @@ -1,11 +1,11 @@ import { ResponsePath, responsePathAsArray, - GraphQLResolveInfo, GraphQLType, } from 'graphql'; +import { ApolloServerPlugin } from "apollo-server-plugin-base"; -import { GraphQLExtension } from 'graphql-extensions'; +const { PACKAGE_NAME } = require("../package.json").name; export interface TracingFormat { version: 1; @@ -33,94 +33,104 @@ interface ResolverCall { endOffset?: HighResolutionTime; } -export class TracingExtension - implements GraphQLExtension { - private startWallTime?: Date; - private endWallTime?: Date; - private startHrTime?: HighResolutionTime; - private duration?: HighResolutionTime; - - private resolverCalls: ResolverCall[] = []; - - public requestDidStart() { - this.startWallTime = new Date(); - this.startHrTime = process.hrtime(); - } - - public executionDidStart() { - // It's a little odd that we record the end time after execution rather than - // at the end of the whole request, but because we need to include our - // formatted trace in the request itself, we have to record it before the - // request is over! It's also odd that we don't do traces for parse or - // validation errors, but runQuery doesn't currently support that, as - // format() is only invoked after execution. - return () => { - this.duration = process.hrtime(this.startHrTime); - this.endWallTime = new Date(); - }; - } - - public willResolveField( - _source: any, - _args: { [argName: string]: any }, - _context: TContext, - info: GraphQLResolveInfo, - ) { - const resolverCall: ResolverCall = { - path: info.path, - fieldName: info.fieldName, - parentType: info.parentType, - returnType: info.returnType, - startOffset: process.hrtime(this.startHrTime), - }; +export const plugin = (_futureOptions = {}) => (): ApolloServerPlugin => ({ + requestDidStart() { + let startWallTime: Date | undefined; + let endWallTime: Date | undefined; + let startHrTime: HighResolutionTime | undefined; + let duration: HighResolutionTime | undefined; + const resolverCalls: ResolverCall[] = []; - this.resolverCalls.push(resolverCall); + startWallTime = new Date(); + startHrTime = process.hrtime(); - return () => { - resolverCall.endOffset = process.hrtime(this.startHrTime); - }; - } - - public format(): [string, TracingFormat] | undefined { - // In the event that we are called prior to the initialization of critical - // date metrics, we'll return undefined to signal that the extension did not - // format properly. Any undefined extension results are simply purged by - // the graphql-extensions module. - if ( - typeof this.startWallTime === 'undefined' || - typeof this.endWallTime === 'undefined' || - typeof this.duration === 'undefined' - ) { - return; - } - - return [ - 'tracing', - { - version: 1, - startTime: this.startWallTime.toISOString(), - endTime: this.endWallTime.toISOString(), - duration: durationHrTimeToNanos(this.duration), - execution: { - resolvers: this.resolverCalls.map(resolverCall => { - const startOffset = durationHrTimeToNanos(resolverCall.startOffset); - const duration = resolverCall.endOffset - ? durationHrTimeToNanos(resolverCall.endOffset) - startOffset - : 0; - return { - path: [...responsePathAsArray(resolverCall.path)], - parentType: resolverCall.parentType.toString(), - fieldName: resolverCall.fieldName, - returnType: resolverCall.returnType.toString(), - startOffset, - duration, - }; - }), + return { + executionDidStart: () => ({ + // It's a little odd that we record the end time after execution rather + // than at the end of the whole request, but because we need to include + // our formatted trace in the request itself, we have to record it + // before the request is over! + + // Historically speaking: It's WAS odd that we don't do traces for parse + // or validation errors. Reason being: at the time that this was written + // (now a plugin but originally an extension)). That was the case + // because runQuery DIDN'T (again, at the time, when it was an + // extension) support that since format() was only invoked after + // execution. + executionDidEnd: () => { + duration = process.hrtime(startHrTime); + endWallTime = new Date(); + }, + + willResolveField({ info }) { + const resolverCall: ResolverCall = { + path: info.path, + fieldName: info.fieldName, + parentType: info.parentType, + returnType: info.returnType, + startOffset: process.hrtime(startHrTime), + }; + + resolverCalls.push(resolverCall); + + return () => { + resolverCall.endOffset = process.hrtime(startHrTime); + }; }, + }), + + willSendResponse({ response }) { + // In the event that we are called prior to the initialization of + // critical date metrics, we'll return undefined to signal that the + // extension did not format properly. Any undefined extension + // results are simply purged by the graphql-extensions module. + if ( + typeof startWallTime === 'undefined' || + typeof endWallTime === 'undefined' || + typeof duration === 'undefined' + ) { + return; + } + + const extensions = + response.extensions || (response.extensions = Object.create(null)); + + // Be defensive and make sure nothing else (other plugin, etc.) has + // already used the `tracing` property on `extensions`. + if (typeof extensions.tracing !== 'undefined') { + throw new Error(PACKAGE_NAME + ": Could not add `tracing` to " + + "`extensions` since `tracing` was unexpectedly already present."); + } + + // Set the extensions. + extensions.tracing = { + version: 1, + startTime: startWallTime.toISOString(), + endTime: endWallTime.toISOString(), + duration: durationHrTimeToNanos(duration), + execution: { + resolvers: resolverCalls.map(resolverCall => { + const startOffset = durationHrTimeToNanos( + resolverCall.startOffset, + ); + const duration = resolverCall.endOffset + ? durationHrTimeToNanos(resolverCall.endOffset) - startOffset + : 0; + return { + path: [...responsePathAsArray(resolverCall.path)], + parentType: resolverCall.parentType.toString(), + fieldName: resolverCall.fieldName, + returnType: resolverCall.returnType.toString(), + startOffset, + duration, + }; + }), + }, + }; }, - ]; - } -} + }; + }, +}) type HighResolutionTime = [number, number]; diff --git a/packages/apollo-tracing/tsconfig.json b/packages/apollo-tracing/tsconfig.json index 0de28001c29..29dff935854 100644 --- a/packages/apollo-tracing/tsconfig.json +++ b/packages/apollo-tracing/tsconfig.json @@ -7,6 +7,6 @@ "include": ["src/**/*"], "exclude": ["**/__tests__", "**/__mocks__"], "references": [ - { "path": "../graphql-extensions" }, + { "path": "../apollo-server-plugin-base" }, ] } diff --git a/packages/graphql-extensions/package.json b/packages/graphql-extensions/package.json index c914bf53c2a..1053a7f68ab 100644 --- a/packages/graphql-extensions/package.json +++ b/packages/graphql-extensions/package.json @@ -1,6 +1,6 @@ { "name": "graphql-extensions", - "version": "0.12.1-alpha.0", + "version": "0.12.2", "description": "Add extensions to GraphQL servers", "main": "./dist/index.js", "types": "./dist/index.d.ts",