From 3270c51140417a85b5f5b8765936773ff09c2780 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Mon, 10 Jan 2022 12:15:19 -0800 Subject: [PATCH] feat(gateway): Implement new `supergraphSdl()` config option for dynamic gateway updates (#1246) (copied from the changelog entry) This change improves the `supergraphSdl` configuration option to provide a clean and flexible interface for updating gateway schema on load and at runtime. This PR brings a number of updates and deprecations to the gateway. Previous options for loading the gateway's supergraph (`serviceList`, `localServiceList`, `experimental_updateServiceDefinitions`, `experimental_supergraphSdl`) are all deprecated going forward. The migration paths all point to the updated `supergraphSdl` configuration option. The most notable change here is the introduction of the concept of a `SupergraphManager` (one new possible type of `supergraphSdl`). This interface (when implemented) provides a means for userland code to update the gateway supergraph dynamically, perform subgraph healthchecks, and access subgraph datasources. All of the mentioned deprecated configurations now either use an implementation of a `SupergraphManager` internally or export one to be configured by the user (`IntrospectAndCompose` and `LocalCompose`). For now: all of the mentioned deprecated configurations will still continue to work as expected. Their usage will come with deprecation warnings advising a switch to `supergraphSdl`. * `serviceList` users should switch to the now-exported `IntrospectAndCompose` class. * `localServiceList` users should switch to the similar `LocalCompose` class. * `experimental_{updateServiceDefinitions|supergraphSdl}` users should migrate their implementation to a custom `SupergraphSdlHook` or `SupergraphManager`. Since the gateway itself is no longer responsible for composition: * `experimental_didUpdateComposition` has been renamed more appropriately to `experimental_didUpdateSupergraph` (no signature change) * `experimental_compositionDidFail` hook is removed `experimental_pollInterval` is deprecated and will issue a warning. Its renamed equivalent is `pollIntervalInMs`. Some defensive code around gateway shutdown has been removed which was only relevant to users who are running the gateway within `ApolloServer` before v2.18. If you are still running one of these versions, server shutdown may not happen as smoothly. --- codegen.yml | 4 +- docs/source/api/apollo-gateway.mdx | 176 +++- docs/source/entities.mdx | 6 +- docs/source/gateway.mdx | 136 ++- docs/source/managed-federation/uplink.mdx | 22 +- docs/source/subgraphs.mdx | 2 +- .../src/fixtures/index.ts | 18 +- federation-js/src/__tests__/joinSpec.test.ts | 2 +- gateway-js/CHANGELOG.md | 19 +- gateway-js/package.json | 4 +- gateway-js/src/__tests__/execution-utils.ts | 4 +- .../__tests__/gateway/buildService.test.ts | 4 +- .../src/__tests__/gateway/composedSdl.test.ts | 44 - .../__tests__/gateway/lifecycle-hooks.test.ts | 157 ++-- .../__tests__/gateway/opentelemetry.test.ts | 11 +- .../__tests__/gateway/queryPlanCache.test.ts | 34 +- .../src/__tests__/gateway/reporting.test.ts | 10 +- .../__tests__/gateway/supergraphSdl.test.ts | 390 +++++++++ .../src/__tests__/integration/aliases.test.ts | 12 +- .../integration/configuration.test.ts | 121 ++- .../src/__tests__/integration/logger.test.ts | 2 +- .../integration/networkRequests.test.ts | 199 ++--- .../src/__tests__/integration/nockMocks.ts | 23 +- gateway-js/src/config.ts | 189 ++++- gateway-js/src/index.ts | 799 +++++++----------- .../__tests__/IntrospectAndCompose.test.ts | 370 ++++++++ .../loadServicesFromRemoteEndpoint.test.ts | 10 +- .../__tests__/tsconfig.json | 8 + .../IntrospectAndCompose/index.ts | 163 ++++ .../loadServicesFromRemoteEndpoint.ts | 12 +- .../supergraphManagers/LegacyFetcher/index.ts | 229 +++++ .../supergraphManagers/LocalCompose/index.ts | 83 ++ .../loadSupergraphSdlFromStorage.test.ts | 8 +- .../UplinkFetcher/__tests__/tsconfig.json | 8 + .../supergraphManagers/UplinkFetcher/index.ts | 128 +++ .../loadSupergraphSdlFromStorage.ts | 6 +- .../UplinkFetcher}/outOfBandReporter.ts | 4 +- gateway-js/src/supergraphManagers/index.ts | 4 + gateway-js/src/utilities/createHash.ts | 10 + gateway-js/src/utilities/isNodeLike.ts | 11 + package-lock.json | 9 +- package.json | 1 + 42 files changed, 2564 insertions(+), 888 deletions(-) delete mode 100644 gateway-js/src/__tests__/gateway/composedSdl.test.ts create mode 100644 gateway-js/src/__tests__/gateway/supergraphSdl.test.ts create mode 100644 gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/IntrospectAndCompose.test.ts rename gateway-js/src/{ => supergraphManagers/IntrospectAndCompose}/__tests__/loadServicesFromRemoteEndpoint.test.ts (81%) create mode 100644 gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/tsconfig.json create mode 100644 gateway-js/src/supergraphManagers/IntrospectAndCompose/index.ts rename gateway-js/src/{ => supergraphManagers/IntrospectAndCompose}/loadServicesFromRemoteEndpoint.ts (88%) create mode 100644 gateway-js/src/supergraphManagers/LegacyFetcher/index.ts create mode 100644 gateway-js/src/supergraphManagers/LocalCompose/index.ts rename gateway-js/src/{ => supergraphManagers/UplinkFetcher}/__tests__/loadSupergraphSdlFromStorage.test.ts (97%) create mode 100644 gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/tsconfig.json create mode 100644 gateway-js/src/supergraphManagers/UplinkFetcher/index.ts rename gateway-js/src/{ => supergraphManagers/UplinkFetcher}/loadSupergraphSdlFromStorage.ts (96%) rename gateway-js/src/{ => supergraphManagers/UplinkFetcher}/outOfBandReporter.ts (96%) create mode 100644 gateway-js/src/supergraphManagers/index.ts create mode 100644 gateway-js/src/utilities/createHash.ts create mode 100644 gateway-js/src/utilities/isNodeLike.ts diff --git a/codegen.yml b/codegen.yml index 0fd158d697..c28c1dcc61 100644 --- a/codegen.yml +++ b/codegen.yml @@ -4,8 +4,8 @@ schema: [ "https://outofbandreporter.api.apollographql.com/", ] documents: - - gateway-js/src/loadSupergraphSdlFromStorage.ts - - gateway-js/src/outOfBandReporter.ts + - gateway-js/src/supergraphManagers/UplinkFetcher/loadSupergraphSdlFromStorage.ts + - gateway-js/src/supergraphManagers/UplinkFetcher/outOfBandReporter.ts generates: gateway-js/src/__generated__/graphqlTypes.ts: plugins: diff --git a/docs/source/api/apollo-gateway.mdx b/docs/source/api/apollo-gateway.mdx index ac31f86662..c6d14cfbe0 100644 --- a/docs/source/api/apollo-gateway.mdx +++ b/docs/source/api/apollo-gateway.mdx @@ -100,11 +100,20 @@ const gateway = new ApolloGateway({ ###### `supergraphSdl` -`String` +`string | SupergraphSdlHook | SupergraphManager` -A [supergraph schema](../#federated-schemas) ([generated with the Rover CLI](https://www.apollographql.com/docs/rover/supergraphs/#composing-a-supergraph-schema)) that's composed from your subgraph schemas. The supergraph schema includes directives that specify routing information for each subgraph. +You provide your supergraph schema to the gateway with this option. You can provide it as a `string`, via a `SupergraphSdlHook`, or via a `SupergraphManager`. + +**When `supergraphSdl` is a `string`:** A [supergraph schema](../#federated-schemas) ([generated with the Rover CLI](https://www.apollographql.com/docs/rover/supergraphs/#composing-a-supergraph-schema)) that's composed from your subgraph schemas. The supergraph schema includes directives that specify routing information for each subgraph. + +**When `supergraphSdl` is a `SupergraphSdlHook`:** This is an `async` function that returns an object containing a `supergraphSdl` string as well as a `cleanup` function. The hook accepts an object containing the following properties: +- `update`: A function that updates the supergraph schema +- `healthCheck`: A function that issues a health check against the subgraphs +- `getDataSource`: A function that gets a data source for a particular subgraph from the gateway + +**When `supergraphSdl` is a `SupergraphManager`:** An object containing an `initialize` property. `initialize` is an `async` function of the `SupergraphSdlHook` type described directly above. **If you are using managed federation,** do not provide this field. @@ -124,7 +133,7 @@ A [supergraph schema](../#federated-schemas) ([generated with the Rover CLI](htt -**This option is discouraged in favor of [`supergraphSdl`](#supergraphsdl).** +**This option is deprecated in favor of the drop-in replacement, [`IntrospectAndCompose`](#class-introspectandcompose).** An array of objects that each specify the `name` and `url` of one subgraph in your federated graph. On startup, the gateway uses this array to obtain your subgraph schemas via introspection and compose a supergraph schema. @@ -143,10 +152,12 @@ You can specify any string value for the `name` field, which is used for identif ###### `introspectionHeaders` -`Object | (service: ServiceEndpointDefinition) => Promise | Object` +`Object | (service: ServiceEndpointDefinition) => (Promise | Object)` +**This option is deprecated in favor of the drop-in replacement, [`IntrospectAndCompose`](#class-introspectandcompose).** + An object (or an optionally async function _returning_ an object) that contains the names and values of HTTP headers that the gateway includes _only_ when making introspection requests to your subgraphs. **If you are using managed federation,** do not provide this field. @@ -557,3 +568,160 @@ The details of the `fetch` response sent by the subgraph. + +## `class IntrospectAndCompose` + +`IntrospectAndCompose` is a development tool for fetching and composing subgraph SDL into a supergraph for your gateway. Given a list of subgraphs and their URLs, `IntrospectAndCompose` will issue queries for their SDL, compose them into a supergraph, and provide that supergraph to the gateway. It can also be configured to update via polling and perform subgraph health checks to ensure that supergraphs are updated safely. `IntrospectAndCompose` implements the `SupergraphManager` interface and is passed in to `ApolloGateway`'s `supergraphSdl` constructor option. + +> `IntrospectAndCompose` is the drop-in replacement for `serviceList`. + +### Methods + +#### `constructor` + +Returns an initialized `IntrospectAndCompose` instance, which you can then pass to the `supergraphSdl` configuration option of the `ApolloGateway` constructor, like so: + +```javascript{3-7} +const server = new ApolloServer({ + gateway: new ApolloGateway({ + supergraphSdl: new IntrospectAndCompose({ + subgraphs: [ + // ... + ], + }), + }), +}); +``` + +Takes an `options` object as a parameter. Supported properties of this object are described below. + +##### Examples + +###### Providing a `subgraphs` list and headers to authorize introspection + +```js +const gateway = new ApolloGateway({ + supergraphSdl: new IntrospectAndCompose({ + subgraphs: [ + { name: 'products', url: 'https://products-service.dev/graphql', + { name: 'reviews', url: 'https://reviews-service.dev/graphql' }, + ], + introspectionHeaders: { + Authorization: 'Bearer abc123' + }, + }), +}); +``` + +###### Configuring the subgraph fetcher + +`IntrospectAndCompose` uses the data sources constructed by `ApolloGateway`. To customize the gateway's data sources, you can provide a [`buildService`](#buildservice) function to the `ApolloGateway` constructor. In the example below, `IntrospectAndCompose` makes authenticated requests to the subgraphs +via the `AuthenticatedDataSource`s that we construct in the gateway's `buildService` function. + +```js +const gateway = new ApolloGateway({ + buildService({ name, url }) { + return new AuthenticatedDataSource({ url }); + }, + supergraphSdl: new IntrospectAndCompose({ + subgraphs: [ + { name: 'products', url: 'https://products-service.dev/graphql', + { name: 'reviews', url: 'https://reviews-service.dev/graphql' }, + ], + }), +}); +``` + +##### Options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +###### `subgraphs` + +`Array` + + +An array of objects that each specify the `name` and `url` of one subgraph in your federated graph. On startup, `IntrospectAndCompose` uses this array to obtain your subgraph schemas via introspection and compose a supergraph schema. + +The `name` field is a string that should be treated as a subgraph's unique identifier. It is used for query planning, logging, and reporting metrics to Apollo Studio. +> For Studio users, subgraph names **must:** +- Begin with a letter (capital or lowercase) +- Include only letters, numbers, underscores (_), and hyphens (-) +- Have a maximum of 64 characters + +
+ +###### `introspectionHeaders` + +`Object | (service: ServiceEndpointDefinition) => (Promise | Object)` + + +An object (or an optionally async function _returning_ an object)that contains the names and values of HTTP headers that the gateway includes _only_ when making introspection requests to your subgraphs. + +**If you define a [`buildService`](#buildservice) function in your `ApolloGateway` config, ** specify these headers in that function instead of providing this option. This ensures that your `buildService` function doesn't inadvertently overwrite the values of any headers you provide here. +
+ +###### `pollIntervalInMs` + +`number` + + +Specify this option to enable supergraph updates via subgraph polling. `IntrospectAndCompose` polls each subgraph at the given interval. +
+ +###### `subgraphHealthCheck` + +`boolean` + + +> This option applies only to subgraphs that are configured for polling via the `pollIntervalInMs` option. +If `true`, the gateway performs a health check on each subgraph before performing a supergraph update. Errors during health checks will result in skipping the supergraph update, but polling will continue. The health check is a simple GraphQL query (`query __ApolloServiceHealthCheck__ { __typename }`) to ensure that subgraphs are reachable and can successfully respond to GraphQL requests. + +**This option is the `IntrospectAndCompose` equivalent of `ApolloGateway`'s `serviceHealthCheck` option. If you are using `IntrospectAndCompose`, enabling `serviceHealthCheck` on your `ApolloGateway` instance has no effect.** +
+ +###### `logger` + +[`Logger`](https://github.com/apollographql/apollo-server/blob/main/packages/apollo-server-types/src/index.ts#L166-L172) + + +An object to use for logging in place of `console`. If provided, this object must implement all methods of [the `Logger` interface](https://github.com/apollographql/apollo-server/blob/main/packages/apollo-server-types/src/index.ts#L166-L172). + +`IntrospectAndCompose` doesn't share the same logger as the `ApolloGateway` it's configured with. In most cases, you probably want to pass the same logger to both `ApolloGateway` and `IntrospectAndCompose`. +
\ No newline at end of file diff --git a/docs/source/entities.mdx b/docs/source/entities.mdx index 4c2f7a4e4b..cb7cded530 100644 --- a/docs/source/entities.mdx +++ b/docs/source/entities.mdx @@ -391,11 +391,11 @@ We're done! `Bill` now originates in a new subgraph, and it was resolvable durin - + -> ⚠️ We strongly recommend _against_ using `serviceList`. For details, see [Limitations of `serviceList`](./gateway/#limitations-of-servicelist). +> ⚠️ We strongly recommend _against_ using `IntrospectAndCompose` in production. For details, see [Limitations of `IntrospectAndCompose`](./gateway/#limitations-of-introspectandcompose). -When you provide a `serviceList` to `ApolloGateway`, it performs composition _itself_ on startup after fetching all of your subgraph schemas. If this runtime composition fails, the gateway fails to start up, resulting in downtime. +When you provide `IntrospectAndCompose` to `ApolloGateway`, it performs composition _itself_ on startup after fetching all of your subgraph schemas. If this runtime composition fails, the gateway fails to start up, resulting in downtime. To minimize downtime for your graph, you need to make sure all of your subgraph schemas successfully compose whenever your gateway starts up. When migrating an entity, this requires a **coordinated deployment** of your modified subgraphs and a restart of the gateway itself. diff --git a/docs/source/gateway.mdx b/docs/source/gateway.mdx index b0cc3ca63c..7d11d35510 100644 --- a/docs/source/gateway.mdx +++ b/docs/source/gateway.mdx @@ -34,12 +34,12 @@ const supergraphSdl = readFileSync('./supergraph.graphql').toString(); // Initialize an ApolloGateway instance and pass it // the supergraph schema const gateway = new ApolloGateway({ - supergraphSdl + supergraphSdl, }); // Pass the ApolloGateway to the ApolloServer constructor const server = new ApolloServer({ - gateway + gateway, }); server.listen().then(({ url }) => { @@ -57,19 +57,119 @@ To learn how to compose your supergraph schema with the Rover CLI, see the [Fede On startup, the gateway processes your `supergraphSdl`, which includes routing information for your subgraphs. It then begins accepting incoming requests and creates query plans for them that execute across one or more subgraphs. -### Composing with `serviceList` +### Updating the supergraph schema + +In the above example, we provide a _static_ supergraph schema to the gateway. This approach requires the gateway to restart in order to update the supergraph schema. This is undesirable for many applications, so we also provide the ability to update the supergraph schema dynamically. + +```js:title=index.js +const { ApolloServer } = require('apollo-server'); +const { ApolloGateway } = require('@apollo/gateway'); +const { readFile } = require('fs/promises'); + +let supergraphUpdate; +const gateway = new ApolloGateway({ + async supergraphSdl({ update }) { + // `update` is a function which we'll save for later use + supergraphUpdate = update; + return { + supergraphSdl: await readFile('./supergraph.graphql', 'utf-8'), + } + }, +}); + +// Pass the ApolloGateway to the ApolloServer constructor +const server = new ApolloServer({ + gateway, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +There are a few things happening here. Let's take a look at each of them individually. + +Note that `supergraphSdl` is now an `async` function. This function is called exactly once, when `ApolloServer` initializes the gateway. It has the following responsibilities: +- It receives the `update` function, which we use to update the supergraph schema. +- It returns the initial supergraph schema, which the gateway uses at startup. + +With the `update` function, we can now programatically update the supergraph. Polling, webhooks, and file watchers are all good examples of ways we can go about updating the supergraph. + +The code below demonstrates a more complete example using a file watcher. In this example, assume that we're updating the `supergraphSdl.graphql` file with the Rover CLI. -> We strongly recommend _against_ using `serviceList`. For details, [see below](#limitations-of-servicelist). +```js:title=index.js +const { ApolloServer } = require('apollo-server'); +const { ApolloGateway } = require('@apollo/gateway'); +const { watch } = require('fs'); +const { readFile } = require('fs/promises'); + +const server = new ApolloServer({ + gateway: new ApolloGateway({ + async supergraphSdl({ update, healthCheck }) { + // create a file watcher + const watcher = watch('./supergraph.graphql'); + // subscribe to file changes + watcher.on('change', async () => { + // update the supergraph schema + try { + const updatedSupergraph = await readFile('./supergraph.graphql', 'utf-8'); + // optional health check update to ensure our services are responsive + await healthCheck(updatedSupergraph); + // update the supergraph schema + update(updatedSupergraph); + } catch (e) { + // handle errors that occur during health check or while updating the supergraph schema + console.error(e); + } + }); + + return { + supergraphSdl: await readFile('./supergraph.graphql', 'utf-8'), + // cleanup is called when the gateway is stopped + async cleanup() { + watcher.close(); + } + } + }, + }), +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +This example is a bit more complete. Let's take a look at what we've added. -Alternatively, you can provide a `serviceList` array to the `ApolloGateway` constructor, like so: +In the `supergraphSdl` callback, we also receive a `healthCheck` function. This enables us to run a health check against each of the services in our future supergraph schema. This is useful for ensuring that our services are responsive and that we don't perform an update when it's unsafe. + +We've also wrapped our call to `update` and `healthCheck` in a `try` block. If an error occurs during either of these, we want to handle this gracefully. In this example, we continue running the existing supergraph schema and log an error. + +Finally, we return a `cleanup` function. This is a callback that's called when the gateway is stopped. This enables us to cleanly shut down any ongoing processes (such as file watching or polling) when the gateway is shut down via a call to `ApolloServer.stop`. The gateway expects `cleanup` to return a `Promise` and `await`s it before shutting down. + +#### Advanced usage + +In a more complex application, you might want to create a class that handles the `update` and `healthCheck` functions, along with any additional state. In this case, you can instead provide an object (or class) with an `initialize` function. This function is called just like the `supergraphSdl` function discussed above. For an example of this, see the [`IntrospectAndCompose` source code](https://github.com/apollographql/federation/blob/main/packages/apollo-gateway/src/supergraphManagers/IntrospectAndCompose/index.ts). + +### Composing subgraphs with `IntrospectAndCompose` + +> Looking for `serviceList`? In `@apollo/gateway` version 0.46.0 and later,`IntrospectAndCompose` is the new drop-in replacement for the `serviceList` option. The `serviceList` option will be removed in an upcoming release of `@apollo/gateway`, but `IntrospectAndCompose` will continue to be supported. We recommend using the Rover CLI to manage local composition, but `IntrospectAndCompose` is still useful for various development and testing workflows. + +> We strongly recommend _against_ using `IntrospectAndCompose` in production. For details, [see below](#limitations-of-introspectandcompose). + +Alternatively, you can provide a `subgraph` array to the `IntrospectAndCompose` constructor, like so: ```js:title=index.js +const { ApolloGateway, IntrospectAndCompose } = require('@apollo/gateway'); + const gateway = new ApolloGateway({ - serviceList: [ - { name: 'accounts', url: 'http://localhost:4001' }, - { name: 'products', url: 'http://localhost:4002' }, - // ...additional subgraphs... - ] + supergraphSdl: new IntrospectAndCompose({ + subgraphs: [ + { name: 'accounts', url: 'http://localhost:4001' }, + { name: 'products', url: 'http://localhost:4002' }, + // ...additional subgraphs... + ], + }), }); ``` @@ -77,21 +177,19 @@ Each item in the array is an object that specifies the `name` and `url` of one o On startup, the gateway fetches each subgraph's schema from its `url` and composes those schemas into a supergraph schema. It then begins accepting incoming requests and creates query plans for them that execute across one or more subgraphs. -However, the `serviceList` option has important [limitations](#limitations-of-servicelist). +Additional configuration options can be found in the [`IntrospectAndCompose` API documentation](./api/apollo-gateway#class-introspectandcompose). -#### Limitations of `serviceList` +However, `IntrospectAndCompose` has important [limitations](#limitations-of-introspectandcompose). -The `serviceList` option can sometimes be helpful for local development, but it is strongly discouraged for any other environment. Here are some reasons why: +#### Limitations of `IntrospectAndCompose` -* **Composition might fail.** With `serviceList`, your gateway performs composition dynamically on startup, which requires network communication with each subgraph. If composition fails, your gateway [throws errors](./errors/) and experiences unplanned downtime. - * With `supergraphSdl`, you instead provide a supergraph schema that has _already_ been composed successfully. This prevents composition errors and enables faster startup. +The `IntrospectAndCompose` option can sometimes be helpful for local development, but it is strongly discouraged for any other environment. Here are some reasons why: + +* **Composition might fail.** With `IntrospectAndCompose`, your gateway performs composition dynamically on startup, which requires network communication with each subgraph. If composition fails, your gateway [throws errors](./errors/) and experiences unplanned downtime. + * With the static or dynamic `supergraphSdl` configuration, you instead provide a supergraph schema that has _already_ been composed successfully. This prevents composition errors and enables faster startup. * **Gateway instances might differ.** If you deploy multiple instances of your gateway _while_ deploying updates to your subgraphs, your gateway instances might fetch different schemas from the _same_ subgraph. This can result in sporadic composition failures or inconsistent supergraph schemas between instances. * When you deploy multiple instances with `supergraphSdl`, you provide the exact same static artifact to each instance, enabling more predictable behavior. -> **We hope to deprecate the `serviceList` option in the coming months**, but we'd love to hear from you if it enables an important use case that either `supergraphSdl` or [managed federation](./managed-federation/overview/) currently doesn't. -> -> Please let us know by [creating an issue](https://github.com/apollographql/federation/issues/new/choose) or [replying to this forum topic](https://community.apollographql.com/t/1053). - ## Updating the gateway > Before updating your gateway's version, check the [changelog](https://github.com/apollographql/federation/blob/main/gateway-js/CHANGELOG.md) for potential breaking changes. diff --git a/docs/source/managed-federation/uplink.mdx b/docs/source/managed-federation/uplink.mdx index b6393a7b47..5601c1c36e 100644 --- a/docs/source/managed-federation/uplink.mdx +++ b/docs/source/managed-federation/uplink.mdx @@ -1,6 +1,7 @@ --- title: Apollo Uplink sidebar_title: Uplink +description: Fetch your managed gateway's configuration --- When using [managed federation](./overview/), your federated gateway regularly polls an endpoint called **Apollo Uplink** for its latest supergraph schema and other configuration: @@ -36,7 +37,11 @@ Even if a particular poll request fails _all_ of its retries, the gateway contin ## Configuring polling behavior -You can configure the number of retries your gateway performs for a failed poll request, along with the list of Uplink URLs your gateway uses. +You can configure the following aspects of your gateway's polling behavior: + +* The number of retries your gateway performs for a failed poll request +* The interval at which your gateway polls +* The list of Uplink URLs your gateway uses ### Retry limit @@ -56,6 +61,21 @@ By default, the gateway retries a single poll request a number of times equal to > Even if a particular poll request fails _all_ of its retries, the gateway continues polling as usual at the next interval (with its own set of retries if needed). In the meantime, the gateway continues using its most recently obtained configuration. +### Poll interval + +You can configure the interval at which your gateway polls Apollo Uplink like so: + +```js +const { ApolloGateway } = require('@apollo/gateway'); + +// ... + +const gateway = new ApolloGateway({ + pollIntervalInMs: 15000 // 15 seconds +}); +``` + +The `pollIntervalInMs` option specifies the polling interval in milliseconds. This value must be at least `10000` (which is also the default value). ### Uplink URLs (advanced) diff --git a/docs/source/subgraphs.mdx b/docs/source/subgraphs.mdx index 8fac6a1ea5..846d90554c 100644 --- a/docs/source/subgraphs.mdx +++ b/docs/source/subgraphs.mdx @@ -197,7 +197,7 @@ The `sdl` field returns your subgraph's schema as an SDL string. This field has * Unlike introspection, the `sdl` field is _not_ disabled by default in production environments (this is safe if you properly [secure your subgraph](#securing-your-subgraphs)). * Unlike introspection, the `sdl` field's returned string includes federation-specific directives like `@key`. -Whenever your gateway needs to fetch a subgraph's schema (this occurs only if your gateway uses [the `serviceList` option](./gateway/#composing-with-servicelist)), it uses this field _instead of_ an introspection query so it can obtain federation-specific details. +Whenever your gateway needs to fetch a subgraph's schema (this occurs only if your gateway uses [`IntrospectAndCompose`](./gateway/#composing-subgraphs-with-introspectandcompose)), it uses this field _instead of_ an introspection query so it can obtain federation-specific details. ### `Query._entities` diff --git a/federation-integration-testsuite-js/src/fixtures/index.ts b/federation-integration-testsuite-js/src/fixtures/index.ts index f2bf9f40d4..bb089d8f19 100644 --- a/federation-integration-testsuite-js/src/fixtures/index.ts +++ b/federation-integration-testsuite-js/src/fixtures/index.ts @@ -7,8 +7,24 @@ import * as reviews from './reviews'; import * as reviewsWithUpdate from './special-cases/reviewsWithUpdate'; import * as accountsWithoutTag from './special-cases/accountsWithoutTag'; import * as reviewsWithoutTag from './special-cases/reviewsWithoutTag'; +import { DocumentNode } from 'graphql'; +import { GraphQLResolverMap } from 'apollo-graphql'; -const fixtures = [accounts, books, documents, inventory, product, reviews]; +export interface Fixture { + name: string; + url: string; + typeDefs: DocumentNode; + resolvers?: GraphQLResolverMap +} + +const fixtures: Fixture[] = [ + accounts, + books, + documents, + inventory, + product, + reviews, +]; const fixturesWithUpdate = [ accounts, diff --git a/federation-js/src/__tests__/joinSpec.test.ts b/federation-js/src/__tests__/joinSpec.test.ts index 1605e755d3..a7d23a5da1 100644 --- a/federation-js/src/__tests__/joinSpec.test.ts +++ b/federation-js/src/__tests__/joinSpec.test.ts @@ -1,7 +1,7 @@ import { fixtures } from 'apollo-federation-integration-testsuite'; import { getJoinDefinitions } from "../joinSpec"; -const questionableNamesRemap = { +const questionableNamesRemap: Record = { accounts: 'ServiceA', books: 'serviceA', documents: 'servicea_2', diff --git a/gateway-js/CHANGELOG.md b/gateway-js/CHANGELOG.md index 5edca3c509..eff1f589f3 100644 --- a/gateway-js/CHANGELOG.md +++ b/gateway-js/CHANGELOG.md @@ -4,7 +4,24 @@ > The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section. -- _Nothing yet! Stay tuned._ +- __BREAKING__: This change improves the `supergraphSdl` configuration option to provide a clean and flexible interface for updating gateway schema on load and at runtime. This PR brings a number of updates and deprecations to the gateway. Previous options for loading the gateway's supergraph (`serviceList`, `localServiceList`, `experimental_updateServiceDefinitions`, `experimental_supergraphSdl`) are all deprecated going forward. The migration paths all point to the updated `supergraphSdl` configuration option. + +The most notable change here is the introduction of the concept of a `SupergraphManager` (one new possible type of `supergraphSdl`). This interface (when implemented) provides a means for userland code to update the gateway supergraph dynamically, perform subgraph healthchecks, and access subgraph datasources. All of the mentioned deprecated configurations now either use an implementation of a `SupergraphManager` internally or export one to be configured by the user (`IntrospectAndCompose` and `LocalCompose`). + +For now: all of the mentioned deprecated configurations will still continue to work as expected. Their usage will come with deprecation warnings advising a switch to `supergraphSdl`. +* `serviceList` users should switch to the now-exported `IntrospectAndCompose` class. +* `localServiceList` users should switch to the similar `LocalCompose` class. +* `experimental_{updateServiceDefinitions|supergraphSdl}` users should migrate their implementation to a custom `SupergraphSdlHook` or `SupergraphManager`. + +Since the gateway itself is no longer responsible for composition: +* `experimental_didUpdateComposition` has been renamed more appropriately to `experimental_didUpdateSupergraph` (no signature change) +* `experimental_compositionDidFail` hook is removed + +`experimental_pollInterval` is deprecated and will issue a warning. Its renamed equivalent is `pollIntervalInMs`. + +Some defensive code around gateway shutdown has been removed which was only relevant to users who are running the gateway within `ApolloServer` before v2.18. If you are still running one of these versions, server shutdown may not happen as smoothly. + +[#1246](https://github.com/apollographql/federation/pull/1246) ## v0.45.1 diff --git a/gateway-js/package.json b/gateway-js/package.json index f084a58044..7688712803 100644 --- a/gateway-js/package.json +++ b/gateway-js/package.json @@ -28,6 +28,7 @@ "@apollo/core-schema": "^0.2.0", "@apollo/federation": "file:../federation-js", "@apollo/query-planner": "file:../query-planner-js", + "@josephg/resolvable": "^1.0.1", "@opentelemetry/api": "^1.0.1", "@types/node-fetch": "2.5.12", "apollo-graphql": "^0.9.5", @@ -39,7 +40,8 @@ "apollo-server-types": "^0.9.0 || ^3.0.0 || ^3.5.0-alpha.0", "loglevel": "^1.6.1", "make-fetch-happen": "^8.0.0", - "pretty-format": "^27.3.1" + "pretty-format": "^27.3.1", + "sha.js": "^2.4.11" }, "peerDependencies": { "graphql": "^15.7.2" diff --git a/gateway-js/src/__tests__/execution-utils.ts b/gateway-js/src/__tests__/execution-utils.ts index d922f887b9..8a177fe4da 100644 --- a/gateway-js/src/__tests__/execution-utils.ts +++ b/gateway-js/src/__tests__/execution-utils.ts @@ -117,8 +117,8 @@ export function getTestingSupergraphSdl(services: typeof fixtures = fixtures) { ); } -export function wait(ms: number) { - return new Promise(r => setTimeout(r, ms)); +export function wait(ms: number, toResolveTo?: any) { + return new Promise((r) => setTimeout(() => r(toResolveTo), ms)); } export function printPlan(queryPlan: QueryPlan): string { diff --git a/gateway-js/src/__tests__/gateway/buildService.test.ts b/gateway-js/src/__tests__/gateway/buildService.test.ts index 6b6e309949..189f57ded8 100644 --- a/gateway-js/src/__tests__/gateway/buildService.test.ts +++ b/gateway-js/src/__tests__/gateway/buildService.test.ts @@ -218,7 +218,7 @@ it('does not share service definition cache between gateways', async () => { url: 'https://api.example.com/repeat', }, ], - experimental_didUpdateComposition: updateObserver, + experimental_didUpdateSupergraph: updateObserver, }); await gateway.load(); @@ -237,7 +237,7 @@ it('does not share service definition cache between gateways', async () => { url: 'https://api.example.com/repeat', }, ], - experimental_didUpdateComposition: updateObserver, + experimental_didUpdateSupergraph: updateObserver, }); await gateway.load(); diff --git a/gateway-js/src/__tests__/gateway/composedSdl.test.ts b/gateway-js/src/__tests__/gateway/composedSdl.test.ts deleted file mode 100644 index 1f8ac19569..0000000000 --- a/gateway-js/src/__tests__/gateway/composedSdl.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ApolloGateway } from '@apollo/gateway'; -import { ApolloServer } from 'apollo-server'; -import { fetch } from '../../__mocks__/apollo-server-env'; -import { getTestingSupergraphSdl } from '../execution-utils'; - -async function getSupergraphSdlGatewayServer() { - const server = new ApolloServer({ - gateway: new ApolloGateway({ - supergraphSdl: getTestingSupergraphSdl(), - }), - }); - - await server.listen({ port: 0 }); - return server; -} - -describe('Using supergraphSdl configuration', () => { - it('successfully starts and serves requests to the proper services', async () => { - const server = await getSupergraphSdlGatewayServer(); - - fetch.mockJSONResponseOnce({ - data: { me: { username: '@jbaxleyiii' } }, - }); - - const result = await server.executeOperation({ - query: '{ me { username } }', - }); - - expect(result.data).toMatchInlineSnapshot(` - Object { - "me": Object { - "username": "@jbaxleyiii", - }, - } - `); - - const [url, request] = fetch.mock.calls[0]; - expect(url).toEqual('https://accounts.api.com'); - expect(request?.body).toEqual( - JSON.stringify({ query: '{me{username}}', variables: {} }), - ); - await server.stop(); - }); -}); diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index c1e9f0badc..c8ede8e642 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -1,8 +1,10 @@ import gql from 'graphql-tag'; import { ApolloGateway } from '../..'; import { + DynamicGatewayConfig, Experimental_DidResolveQueryPlanCallback, Experimental_UpdateServiceDefinitions, + ServiceDefinitionUpdate, } from '../../config'; import { product, @@ -11,8 +13,11 @@ import { accounts, books, documents, + fixtures, + fixturesWithUpdate, } from 'apollo-federation-integration-testsuite'; import { Logger } from 'apollo-server-types'; +import resolvable from '@josephg/resolvable'; // The order of this was specified to preserve existing test coverage. Typically // we would just import and use the `fixtures` array. @@ -47,16 +52,14 @@ beforeEach(() => { describe('lifecycle hooks', () => { it('uses updateServiceDefinitions override', async () => { - const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = jest.fn( - async () => { + const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = + jest.fn(async () => { return { serviceDefinitions, isNewSchema: true }; - }, - ); + }); const gateway = new ApolloGateway({ serviceList: serviceDefinitions, experimental_updateServiceDefinitions, - experimental_didUpdateComposition: jest.fn(), logger, }); @@ -67,39 +70,7 @@ describe('lifecycle hooks', () => { await gateway.stop(); }); - it('calls experimental_didFailComposition with a bad config', async () => { - const experimental_didFailComposition = jest.fn(); - - const gateway = new ApolloGateway({ - async experimental_updateServiceDefinitions() { - return { - serviceDefinitions: [serviceDefinitions[0]], - compositionMetadata: { - formatVersion: 1, - id: 'abc', - implementingServiceLocations: [], - schemaHash: 'abc', - }, - isNewSchema: true, - }; - }, - serviceList: [], - experimental_didFailComposition, - logger, - }); - - await expect(gateway.load()).rejects.toThrowError(); - - const callbackArgs = experimental_didFailComposition.mock.calls[0][0]; - expect(callbackArgs.serviceList).toHaveLength(1); - expect(callbackArgs.errors[0]).toMatchInlineSnapshot( - `[GraphQLError: [product] Book -> \`Book\` is an extension type, but \`Book\` is not defined in any service]`, - ); - expect(callbackArgs.compositionMetadata.id).toEqual('abc'); - expect(experimental_didFailComposition).toBeCalled(); - }); - - it('calls experimental_didUpdateComposition on schema update', async () => { + it('calls experimental_didUpdateSupergraph on schema update', async () => { const compositionMetadata = { formatVersion: 1, id: 'abc', @@ -107,81 +78,73 @@ describe('lifecycle hooks', () => { schemaHash: 'hash1', }; - const update: Experimental_UpdateServiceDefinitions = async () => ({ - serviceDefinitions, - isNewSchema: true, - compositionMetadata: { - ...compositionMetadata, - id: '123', - schemaHash: 'hash2', - }, - }); - - // This is the simplest way I could find to achieve mocked functions that leverage our types - const mockUpdate = jest.fn(update); - - // We want to return a different composition across two ticks, so we mock it - // slightly differenty - mockUpdate.mockImplementationOnce(async () => { - const services = serviceDefinitions.filter(s => s.name !== 'books'); - return { - serviceDefinitions: [ - ...services, - { - name: 'book', - typeDefs: books.typeDefs, - url: 'http://localhost:32542', + const mockUpdate = jest + .fn, [config: DynamicGatewayConfig]>() + .mockImplementationOnce(async () => { + return { + serviceDefinitions: fixtures, + isNewSchema: true, + compositionMetadata: { + ...compositionMetadata, + id: '123', + schemaHash: 'hash2', }, - ], - isNewSchema: true, - compositionMetadata, - }; - }); + }; + }) + // We want to return a different composition across two ticks, so we mock it + // slightly differently + .mockImplementationOnce(async () => { + return { + serviceDefinitions: fixturesWithUpdate, + isNewSchema: true, + compositionMetadata, + }; + }); const mockDidUpdate = jest.fn(); const gateway = new ApolloGateway({ experimental_updateServiceDefinitions: mockUpdate, - experimental_didUpdateComposition: mockDidUpdate, + experimental_didUpdateSupergraph: mockDidUpdate, logger, }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; - let resolve1: Function; - let resolve2: Function; - const schemaChangeBlocker1 = new Promise(res => (resolve1 = res)); - const schemaChangeBlocker2 = new Promise(res => (resolve2 = res)); + const schemaChangeBlocker1 = resolvable(); + const schemaChangeBlocker2 = resolvable(); - gateway.onSchemaChange( + gateway.onSchemaLoadOrUpdate( jest .fn() - .mockImplementationOnce(() => resolve1()) - .mockImplementationOnce(() => resolve2()), + .mockImplementationOnce(() => schemaChangeBlocker1.resolve()) + .mockImplementationOnce(() => schemaChangeBlocker2.resolve()), ); await gateway.load(); await schemaChangeBlocker1; + expect(mockUpdate).toBeCalledTimes(1); expect(mockDidUpdate).toBeCalledTimes(1); await schemaChangeBlocker2; + expect(mockUpdate).toBeCalledTimes(2); expect(mockDidUpdate).toBeCalledTimes(2); const [firstCall, secondCall] = mockDidUpdate.mock.calls; - expect(firstCall[0]!.schema).toBeDefined(); - expect(firstCall[0].compositionMetadata!.schemaHash).toEqual('hash1'); + const expectedFirstId = '562c22b3382b56b1651944a96e89a361fe847b9b32660eae5ecbd12adc20bf8b' + expect(firstCall[0]!.compositionId).toEqual(expectedFirstId); // first call should have no second "previous" argument expect(firstCall[1]).toBeUndefined(); - expect(secondCall[0].schema).toBeDefined(); - expect(secondCall[0].compositionMetadata!.schemaHash).toEqual('hash2'); + expect(secondCall[0]!.compositionId).toEqual( + '0ced02894592ade4376276d11735b46723eb84850c32765cb78502ba5c29a563', + ); // second call should have previous info in the second arg - expect(secondCall[1]!.schema).toBeDefined(); - expect(secondCall[1]!.compositionMetadata!.schemaHash).toEqual('hash1'); + expect(secondCall[1]!.compositionId).toEqual(expectedFirstId); await gateway.stop(); }); @@ -206,34 +169,31 @@ describe('lifecycle hooks', () => { it('warns when polling on the default fetcher', async () => { new ApolloGateway({ serviceList: serviceDefinitions, - experimental_pollInterval: 10, + pollIntervalInMs: 10, logger, }); - expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith( 'Polling running services is dangerous and not recommended in production. Polling should only be used against a registry. If you are polling running services, use with caution.', ); }); it('registers schema change callbacks when experimental_pollInterval is set for unmanaged configs', async () => { - const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = jest.fn( - async (_config) => { + const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = + jest.fn(async (_config) => { return { serviceDefinitions, isNewSchema: true }; - }, - ); + }); const gateway = new ApolloGateway({ serviceList: [{ name: 'book', url: 'http://localhost:32542' }], experimental_updateServiceDefinitions, - experimental_pollInterval: 100, + pollIntervalInMs: 100, logger, }); - let resolve: Function; - const schemaChangeBlocker = new Promise(res => (resolve = res)); - const schemaChangeCallback = jest.fn(() => resolve()); + const schemaChangeBlocker = resolvable(); + const schemaChangeCallback = jest.fn(() => schemaChangeBlocker.resolve()); - gateway.onSchemaChange(schemaChangeCallback); + gateway.onSchemaLoadOrUpdate(schemaChangeCallback); await gateway.load(); await schemaChangeBlocker; @@ -243,12 +203,11 @@ describe('lifecycle hooks', () => { }); it('calls experimental_didResolveQueryPlan when executor is called', async () => { - const experimental_didResolveQueryPlan: Experimental_DidResolveQueryPlanCallback = jest.fn() + const experimental_didResolveQueryPlan: Experimental_DidResolveQueryPlanCallback = + jest.fn(); const gateway = new ApolloGateway({ - localServiceList: [ - books - ], + localServiceList: [books], experimental_didResolveQueryPlan, }); @@ -258,7 +217,7 @@ describe('lifecycle hooks', () => { { book(isbn: "0262510871") { year } } `; - // @ts-ignore + // @ts-ignore await executor({ source, document: gql(source), diff --git a/gateway-js/src/__tests__/gateway/opentelemetry.test.ts b/gateway-js/src/__tests__/gateway/opentelemetry.test.ts index d1e40dcf76..bdca057f5b 100644 --- a/gateway-js/src/__tests__/gateway/opentelemetry.test.ts +++ b/gateway-js/src/__tests__/gateway/opentelemetry.test.ts @@ -36,11 +36,16 @@ describe('opentelemetry', () => { describe('with local data', () => { async function gateway() { + const localDataSources = Object.fromEntries( + fixtures.map((f) => [ + f.name, + new LocalGraphQLDataSource(buildSubgraphSchema(f)), + ]), + ); const gateway = new ApolloGateway({ localServiceList: fixtures, - buildService: service => { - // @ts-ignore - return new LocalGraphQLDataSource(buildSubgraphSchema([service])); + buildService(service) { + return localDataSources[service.name]; }, }); diff --git a/gateway-js/src/__tests__/gateway/queryPlanCache.test.ts b/gateway-js/src/__tests__/gateway/queryPlanCache.test.ts index b8ef499d3c..f0ef311591 100644 --- a/gateway-js/src/__tests__/gateway/queryPlanCache.test.ts +++ b/gateway-js/src/__tests__/gateway/queryPlanCache.test.ts @@ -6,14 +6,20 @@ import { LocalGraphQLDataSource } from '../../datasources/LocalGraphQLDataSource import { ApolloGateway } from '../../'; import { fixtures } from 'apollo-federation-integration-testsuite'; import { QueryPlanner } from '@apollo/query-planner'; + it('caches the query plan for a request', async () => { const buildQueryPlanSpy = jest.spyOn(QueryPlanner.prototype, 'buildQueryPlan'); + const localDataSources = Object.fromEntries( + fixtures.map((f) => [ + f.name, + new LocalGraphQLDataSource(buildSubgraphSchema(f)), + ]), + ); const gateway = new ApolloGateway({ localServiceList: fixtures, - buildService: service => { - // @ts-ignore - return new LocalGraphQLDataSource(buildSubgraphSchema([service])); + buildService(service) { + return localDataSources[service.name]; }, }); @@ -65,11 +71,17 @@ it('supports multiple operations and operationName', async () => { } `; + const localDataSources = Object.fromEntries( + fixtures.map((f) => [ + f.name, + new LocalGraphQLDataSource(buildSubgraphSchema(f)), + ]), + ); + const gateway = new ApolloGateway({ localServiceList: fixtures, - buildService: service => { - // @ts-ignore - return new LocalGraphQLDataSource(buildSubgraphSchema([service])); + buildService(service) { + return localDataSources[service.name]; }, }); @@ -170,11 +182,15 @@ it('does not corrupt cached queryplan data across requests', async () => { }, }; + const dataSources: Record = { + a: new LocalGraphQLDataSource(buildSubgraphSchema(serviceA)), + b: new LocalGraphQLDataSource(buildSubgraphSchema(serviceB)), + }; + const gateway = new ApolloGateway({ localServiceList: [serviceA, serviceB], - buildService: service => { - // @ts-ignore - return new LocalGraphQLDataSource(buildSubgraphSchema([service])); + buildService(service) { + return dataSources[service.name]; }, }); diff --git a/gateway-js/src/__tests__/gateway/reporting.test.ts b/gateway-js/src/__tests__/gateway/reporting.test.ts index 68fa96d929..7653dc8c0f 100644 --- a/gateway-js/src/__tests__/gateway/reporting.test.ts +++ b/gateway-js/src/__tests__/gateway/reporting.test.ts @@ -13,6 +13,7 @@ import { Plugin, Config, Refs } from 'pretty-format'; import { Report, Trace } from 'apollo-reporting-protobuf'; import { fixtures } from 'apollo-federation-integration-testsuite'; import { nockAfterEach, nockBeforeEach } from '../nockAssertions'; +import resolvable, { Resolvable } from '@josephg/resolvable'; // Normalize specific fields that change often (eg timestamps) to static values, // to make snapshot testing viable. (If these helpers are more generally @@ -89,19 +90,16 @@ describe('reporting', () => { let backendServers: ApolloServer[]; let gatewayServer: ApolloServer; let gatewayUrl: string; - let reportPromise: Promise; + let reportPromise: Resolvable; beforeEach(async () => { - let reportResolver: (report: any) => void; - reportPromise = new Promise((resolve) => { - reportResolver = resolve; - }); + reportPromise = resolvable(); nockBeforeEach(); nock('https://usage-reporting.api.apollographql.com') .post('/api/ingress/traces') .reply(200, (_: any, requestBody: string) => { - reportResolver(requestBody); + reportPromise.resolve(requestBody); return 'ok'; }); diff --git a/gateway-js/src/__tests__/gateway/supergraphSdl.test.ts b/gateway-js/src/__tests__/gateway/supergraphSdl.test.ts new file mode 100644 index 0000000000..e24b267734 --- /dev/null +++ b/gateway-js/src/__tests__/gateway/supergraphSdl.test.ts @@ -0,0 +1,390 @@ +import { + ApolloGateway, + SubgraphHealthCheckFunction, + SupergraphSdlUpdateFunction, +} from '@apollo/gateway'; +import { fixturesWithUpdate } from 'apollo-federation-integration-testsuite'; +import { createHash } from 'apollo-graphql/lib/utilities/createHash'; +import { ApolloServer } from 'apollo-server'; +import { Logger } from 'apollo-server-types'; +import { fetch } from '../../__mocks__/apollo-server-env'; +import { getTestingSupergraphSdl } from '../execution-utils'; +import { mockAllServicesHealthCheckSuccess } from '../integration/nockMocks'; +import resolvable from '@josephg/resolvable'; +import { nockAfterEach, nockBeforeEach } from '../nockAssertions'; + +async function getSupergraphSdlGatewayServer() { + const server = new ApolloServer({ + gateway: new ApolloGateway({ + supergraphSdl: getTestingSupergraphSdl(), + }), + }); + + await server.listen({ port: 0 }); + return server; +} + +let logger: Logger; +let gateway: ApolloGateway | null; +beforeEach(() => { + nockBeforeEach(); + + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +}); + +afterEach(async () => { + nockAfterEach(); + + if (gateway) { + await gateway.stop(); + gateway = null; + } +}); + +describe('Using supergraphSdl static configuration', () => { + it('successfully starts and serves requests to the proper services', async () => { + const server = await getSupergraphSdlGatewayServer(); + + fetch.mockJSONResponseOnce({ + data: { me: { username: '@jbaxleyiii' } }, + }); + + const result = await server.executeOperation({ + query: '{ me { username } }', + }); + + expect(result.data).toMatchInlineSnapshot(` + Object { + "me": Object { + "username": "@jbaxleyiii", + }, + } + `); + + const [url, request] = fetch.mock.calls[0]; + expect(url).toEqual('https://accounts.api.com'); + expect(request?.body).toEqual( + JSON.stringify({ query: '{me{username}}', variables: {} }), + ); + await server.stop(); + }); +}); + +describe('Using supergraphSdl dynamic configuration', () => { + it('calls the user provided function after `gateway.load()` is called', async () => { + const callbackSpy = jest.fn(async () => ({ + supergraphSdl: getTestingSupergraphSdl(), + })); + + gateway = new ApolloGateway({ + supergraphSdl: callbackSpy, + }); + + expect(callbackSpy).not.toHaveBeenCalled(); + await gateway.load(); + expect(callbackSpy).toHaveBeenCalled(); + }); + + it('starts and remains in `initialized` state until `supergraphSdl` Promise resolves', async () => { + const promiseGuaranteeingWeAreInTheCallback = resolvable(); + const promiseGuaranteeingWeStayInTheCallback = resolvable(); + + gateway = new ApolloGateway({ + async supergraphSdl() { + promiseGuaranteeingWeAreInTheCallback.resolve(); + await promiseGuaranteeingWeStayInTheCallback; + return { + supergraphSdl: getTestingSupergraphSdl(), + }; + }, + }); + + expect(gateway.__testing().state.phase).toEqual('initialized'); + + const gatewayLoaded = gateway.load(); + await promiseGuaranteeingWeAreInTheCallback; + expect(gateway.__testing().state.phase).toEqual('initialized'); + + promiseGuaranteeingWeStayInTheCallback.resolve(); + await gatewayLoaded; + expect(gateway.__testing().state.phase).toEqual('loaded'); + }); + + it('moves from `initialized` to `loaded` state after calling `load()` and after user Promise resolves', async () => { + const userPromise = resolvable<{ supergraphSdl: string }>(); + + gateway = new ApolloGateway({ + async supergraphSdl() { + return userPromise; + }, + }); + + const loadPromise = gateway.load(); + expect(gateway.__testing().state.phase).toEqual('initialized'); + + const supergraphSdl = getTestingSupergraphSdl(); + const expectedCompositionId = createHash('sha256') + .update(supergraphSdl) + .digest('hex'); + userPromise.resolve({ supergraphSdl }); + + await loadPromise; + const { state, compositionId } = gateway.__testing(); + expect(state.phase).toEqual('loaded'); + expect(compositionId).toEqual(expectedCompositionId); + }); + + it('updates its supergraph after user calls update function', async () => { + const userPromise = resolvable<{ supergraphSdl: string }>(); + + let userUpdateFn: SupergraphSdlUpdateFunction; + gateway = new ApolloGateway({ + async supergraphSdl({ update }) { + userUpdateFn = update; + return userPromise; + }, + }); + + const supergraphSdl = getTestingSupergraphSdl(); + const expectedId = createHash('sha256').update(supergraphSdl).digest('hex'); + userPromise.resolve({ supergraphSdl: getTestingSupergraphSdl() }); + await gateway.load(); + expect(gateway.__testing().compositionId).toEqual(expectedId); + + const updatedSupergraphSdl = getTestingSupergraphSdl(fixturesWithUpdate); + const expectedUpdatedId = createHash('sha256') + .update(updatedSupergraphSdl) + .digest('hex'); + + userUpdateFn!(updatedSupergraphSdl); + expect(gateway.__testing().compositionId).toEqual(expectedUpdatedId); + }); + + it('calls user-provided `cleanup` function when stopped', async () => { + const cleanup = jest.fn(() => Promise.resolve()); + gateway = new ApolloGateway({ + async supergraphSdl() { + return { + supergraphSdl: getTestingSupergraphSdl(), + cleanup, + }; + }, + }); + + await gateway.load(); + const { state, compositionId } = gateway.__testing(); + expect(state.phase).toEqual('loaded'); + expect(compositionId).toEqual( + '562c22b3382b56b1651944a96e89a361fe847b9b32660eae5ecbd12adc20bf8b', + ); + + await gateway.stop(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('performs a successful health check on subgraphs', async () => { + mockAllServicesHealthCheckSuccess(); + + let healthCheckCallback: SubgraphHealthCheckFunction; + const supergraphSdl = getTestingSupergraphSdl(); + gateway = new ApolloGateway({ + async supergraphSdl({ healthCheck }) { + healthCheckCallback = healthCheck; + return { + supergraphSdl, + }; + }, + }); + + await gateway.load(); + const { state, compositionId } = gateway.__testing(); + expect(state.phase).toEqual('loaded'); + expect(compositionId).toEqual( + '562c22b3382b56b1651944a96e89a361fe847b9b32660eae5ecbd12adc20bf8b', + ); + + await expect(healthCheckCallback!(supergraphSdl)).resolves.toBeUndefined(); + }); + + it('calls `initialize` on an object provided to `supergraphSdl`', async () => { + const MockSdlUpdatingClass = { + initialize() { + return Promise.resolve({ + supergraphSdl: getTestingSupergraphSdl(), + }); + }, + }; + const initializeSpy = jest.spyOn(MockSdlUpdatingClass, 'initialize'); + + gateway = new ApolloGateway({ + supergraphSdl: MockSdlUpdatingClass, + }); + + expect(initializeSpy).not.toHaveBeenCalled(); + await gateway.load(); + expect(initializeSpy).toHaveBeenCalled(); + }); + + describe('errors', () => { + it('fails to load if `SupergraphManager` throws on initialization', async () => { + const failureMessage = 'Error from supergraphSdl function'; + gateway = new ApolloGateway({ + async supergraphSdl() { + throw new Error(failureMessage); + }, + logger, + }); + + await expect(gateway.load()).rejects.toThrowError(failureMessage); + + expect(gateway.__testing().state.phase).toEqual('failed to load'); + // we don't want the `afterEach` to call `gateway.stop()` in this case + // since it would throw an error due to the gateway's failed to load state + gateway = null; + }); + + it('gracefully handles Promise rejections from user `cleanup` function', async () => { + const rejectionMessage = 'thrown from cleanup function'; + const cleanup = jest.fn(() => Promise.reject(rejectionMessage)); + gateway = new ApolloGateway({ + async supergraphSdl() { + return { + supergraphSdl: getTestingSupergraphSdl(), + cleanup, + }; + }, + logger, + }); + + await gateway.load(); + await expect(gateway.stop()).resolves.toBeUndefined(); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + 'Error occured while calling user provided `cleanup` function: ' + + rejectionMessage, + ); + }); + + it('throws an error when `healthCheck` rejects', async () => { + // no mocks, so nock will reject + let healthCheckCallback: SubgraphHealthCheckFunction; + const supergraphSdl = getTestingSupergraphSdl(); + gateway = new ApolloGateway({ + async supergraphSdl({ healthCheck }) { + healthCheckCallback = healthCheck; + return { + supergraphSdl, + }; + }, + }); + + await gateway.load(); + const { state, compositionId } = gateway.__testing(); + expect(state.phase).toEqual('loaded'); + expect(compositionId).toEqual( + '562c22b3382b56b1651944a96e89a361fe847b9b32660eae5ecbd12adc20bf8b', + ); + + await expect(healthCheckCallback!(supergraphSdl)).rejects.toThrowError( + /The gateway subgraphs health check failed\. Updating to the provided `supergraphSdl` will likely result in future request failures to subgraphs\. The following error occurred during the health check/, + ); + }); + + it('throws an error when `update` is called after gateway fails to load', async () => { + let updateCallback: SupergraphSdlUpdateFunction; + const supergraphSdl = getTestingSupergraphSdl(); + gateway = new ApolloGateway({ + async supergraphSdl({ update }) { + updateCallback = update; + return { + supergraphSdl: 'invalid SDL', + }; + }, + }); + + try { + await gateway.load(); + } catch {} + + expect(() => + updateCallback!(supergraphSdl), + ).toThrowErrorMatchingInlineSnapshot( + `"Can't call \`update\` callback after gateway failed to load."`, + ); + + // gateway failed to load, so we don't want the `afterEach` to call `gateway.stop()` + gateway = null; + }); + + it('throws an error when `update` is called while an update is in progress', async () => { + let updateCallback: SupergraphSdlUpdateFunction; + const supergraphSdl = getTestingSupergraphSdl(); + gateway = new ApolloGateway({ + async supergraphSdl({ update }) { + updateCallback = update; + return { + supergraphSdl, + }; + }, + experimental_didUpdateSupergraph() { + updateCallback(getTestingSupergraphSdl(fixturesWithUpdate)); + }, + }); + + await expect(gateway.load()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't call \`update\` callback while supergraph update is in progress."`, + ); + + // gateway failed to load, so we don't want the `afterEach` to call `gateway.stop()` + gateway = null; + }); + + it('throws an error when `update` is called after gateway is stopped', async () => { + let updateCallback: SupergraphSdlUpdateFunction; + const supergraphSdl = getTestingSupergraphSdl(); + gateway = new ApolloGateway({ + async supergraphSdl({ update }) { + updateCallback = update; + return { + supergraphSdl, + }; + }, + }); + + await gateway.load(); + await gateway.stop(); + + expect(() => + updateCallback!(getTestingSupergraphSdl(fixturesWithUpdate)), + ).toThrowErrorMatchingInlineSnapshot( + `"Can't call \`update\` callback after gateway has been stopped."`, + ); + }); + + it('throws an error when `update` is called with an invalid supergraph', async () => { + let updateCallback: SupergraphSdlUpdateFunction; + const supergraphSdl = getTestingSupergraphSdl(); + gateway = new ApolloGateway({ + async supergraphSdl({ update }) { + updateCallback = update; + return { + supergraphSdl, + }; + }, + }); + + await gateway.load(); + + expect(() => + updateCallback!('invalid SDL'), + ).toThrowErrorMatchingInlineSnapshot( + `"Syntax Error: Unexpected Name \\"invalid\\"."`, + ); + }); + }); +}); diff --git a/gateway-js/src/__tests__/integration/aliases.test.ts b/gateway-js/src/__tests__/integration/aliases.test.ts index 354d61f8a1..956c527f1f 100644 --- a/gateway-js/src/__tests__/integration/aliases.test.ts +++ b/gateway-js/src/__tests__/integration/aliases.test.ts @@ -141,11 +141,17 @@ it('supports aliases of nested fields on subservices', async () => { // TODO after we remove GraphQLExtensions from ApolloServer, this can go away it('supports aliases when using ApolloServer', async () => { + const localDataSources = Object.fromEntries( + fixtures.map((f) => [ + f.name, + new LocalGraphQLDataSource(buildSubgraphSchema(f)), + ]), + ); + const gateway = new ApolloGateway({ localServiceList: fixtures, - buildService: service => { - // @ts-ignore - return new LocalGraphQLDataSource(buildSubgraphSchema([service])); + buildService(service) { + return localDataSources[service.name]; }, }); diff --git a/gateway-js/src/__tests__/integration/configuration.test.ts b/gateway-js/src/__tests__/integration/configuration.test.ts index 3122b16bdd..79562da448 100644 --- a/gateway-js/src/__tests__/integration/configuration.test.ts +++ b/gateway-js/src/__tests__/integration/configuration.test.ts @@ -12,11 +12,11 @@ import { mockCloudConfigUrl3, } from './nockMocks'; import { getTestingSupergraphSdl } from '../execution-utils'; -import { MockService } from './networkRequests.test'; +import { fixtures, Fixture } from 'apollo-federation-integration-testsuite'; let logger: Logger; -const service: MockService = { +const service: Fixture = { name: 'accounts', url: 'http://localhost:4001', typeDefs: gql` @@ -326,14 +326,17 @@ describe('gateway config / env behavior', () => { APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT: 'env-config', }); - gateway = new ApolloGateway({ + const config = { logger, uplinkEndpoints: [mockCloudConfigUrl1, mockCloudConfigUrl2, mockCloudConfigUrl3], - }); + }; + gateway = new ApolloGateway(config); - expect(gateway['uplinkEndpoints']).toEqual( - [mockCloudConfigUrl1, mockCloudConfigUrl2, mockCloudConfigUrl3], - ); + expect(gateway['getUplinkEndpoints'](config)).toEqual([ + mockCloudConfigUrl1, + mockCloudConfigUrl2, + mockCloudConfigUrl3, + ]); gateway = null; }); @@ -345,16 +348,110 @@ describe('gateway config / env behavior', () => { APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT: 'env-config', }); - gateway = new ApolloGateway({ + const config = { logger, schemaConfigDeliveryEndpoint: 'code-config', - }); + }; + gateway = new ApolloGateway(config); - expect(gateway['uplinkEndpoints']).toEqual( - ['code-config'], - ); + expect(gateway['getUplinkEndpoints'](config)).toEqual(['code-config']); gateway = null; }); }); }); + +describe('deprecation warnings', () => { + it('warns with `experimental_updateSupergraphSdl` option set', async () => { + const gateway = new ApolloGateway({ + async experimental_updateSupergraphSdl() { + return { + id: 'supergraph', + supergraphSdl: getTestingSupergraphSdl(), + }; + }, + logger, + }); + + await gateway.load(); + + expect(logger.warn).toHaveBeenCalledWith( + 'The `experimental_updateSupergraphSdl` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the function form of the `supergraphSdl` configuration option.', + ); + + await gateway.stop(); + }); + + it('warns with `experimental_updateServiceDefinitions` option set', async () => { + const gateway = new ApolloGateway({ + async experimental_updateServiceDefinitions() { + return { + isNewSchema: false, + }; + }, + logger, + }); + + try { + await gateway.load(); + // gateway will throw since we're not providing an actual service list, disregard + } catch {} + + expect(logger.warn).toHaveBeenCalledWith( + 'The `experimental_updateServiceDefinitions` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the function form of the `supergraphSdl` configuration option.', + ); + }); + + it('warns with `serviceList` option set', async () => { + const gateway = new ApolloGateway({ + serviceList: [{ name: 'accounts', url: 'http://localhost:4001' }], + logger, + }); + + try { + await gateway.load(); + // gateway will throw since we haven't mocked these requests, unimportant for this test + } catch {} + + expect(logger.warn).toHaveBeenCalledWith( + 'The `serviceList` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to its replacement `IntrospectAndCompose`. More information on `IntrospectAndCompose` can be found in the documentation.', + ); + }); + + it('warns with `localServiceList` option set', async () => { + const gateway = new ApolloGateway({ + localServiceList: fixtures, + logger, + }); + + await gateway.load(); + + expect(logger.warn).toHaveBeenCalledWith( + 'The `localServiceList` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the `LocalCompose` supergraph manager exported by `@apollo/gateway`.', + ); + + await gateway.stop(); + }); + + it('warns with `schemaConfigDeliveryEndpoint` option set', async () => { + new ApolloGateway({ + schemaConfigDeliveryEndpoint: 'test', + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith( + 'The `schemaConfigDeliveryEndpoint` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the equivalent (array form) `uplinkEndpoints` configuration option.', + ); + }); + + it('warns with `experimental_pollInterval` option set', async () => { + new ApolloGateway({ + experimental_pollInterval: 10000, + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith( + 'The `experimental_pollInterval` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the equivalent `pollIntervalInMs` configuration option.', + ); + }); +}); diff --git a/gateway-js/src/__tests__/integration/logger.test.ts b/gateway-js/src/__tests__/integration/logger.test.ts index 67c287b767..9d49bea54b 100644 --- a/gateway-js/src/__tests__/integration/logger.test.ts +++ b/gateway-js/src/__tests__/integration/logger.test.ts @@ -10,7 +10,7 @@ import * as log4js from "log4js"; const LOWEST_LOG_LEVEL = "debug"; -const KNOWN_DEBUG_MESSAGE = "Checking for composition updates..."; +const KNOWN_DEBUG_MESSAGE = "Gateway successfully initialized (but not yet loaded)"; async function triggerKnownDebugMessage(logger: Logger) { // Trigger a known error. diff --git a/gateway-js/src/__tests__/integration/networkRequests.test.ts b/gateway-js/src/__tests__/integration/networkRequests.test.ts index a86437f56a..9e61264ae7 100644 --- a/gateway-js/src/__tests__/integration/networkRequests.test.ts +++ b/gateway-js/src/__tests__/integration/networkRequests.test.ts @@ -1,5 +1,5 @@ import gql from 'graphql-tag'; -import { DocumentNode, GraphQLObjectType, GraphQLSchema } from 'graphql'; +import { GraphQLObjectType, GraphQLSchema } from 'graphql'; import mockedEnv from 'mocked-env'; import { Logger } from 'apollo-server-types'; import { ApolloGateway } from '../..'; @@ -19,6 +19,7 @@ import { accounts, books, documents, + Fixture, fixturesWithUpdate, inventory, product, @@ -26,14 +27,9 @@ import { } from 'apollo-federation-integration-testsuite'; import { getTestingSupergraphSdl } from '../execution-utils'; import { nockAfterEach, nockBeforeEach } from '../nockAssertions'; +import resolvable from '@josephg/resolvable'; -export interface MockService { - name: string; - url: string; - typeDefs: DocumentNode; -} - -const simpleService: MockService = { +const simpleService: Fixture = { name: 'accounts', url: 'http://localhost:4001', typeDefs: gql` @@ -137,28 +133,39 @@ it('Updates Supergraph SDL from remote storage', async () => { // This test is only interested in the second time the gateway notifies of an // update, since the first happens on load. - let secondUpdateResolve: Function; - const secondUpdate = new Promise((res) => (secondUpdateResolve = res)); - const schemaChangeCallback = jest - .fn() - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => { - secondUpdateResolve(); - }); + const secondUpdate = resolvable(); gateway = new ApolloGateway({ logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; - gateway.onSchemaLoadOrUpdate(schemaChangeCallback); + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; + + const schemas: GraphQLSchema[] = []; + gateway.onSchemaLoadOrUpdate(({apiSchema}) => { + schemas.push(apiSchema); + }); + gateway.onSchemaLoadOrUpdate( + jest + .fn() + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => secondUpdate.resolve()), + ); await gateway.load(mockApolloConfig); - expect(gateway['compositionId']).toMatchInlineSnapshot(`"originalId-1234"`); await secondUpdate; - expect(gateway['compositionId']).toMatchInlineSnapshot(`"updatedId-5678"`); + + // First schema has no 'review' field on the 'Query' type + expect( + (schemas[0].getType('Query') as GraphQLObjectType).getFields()['review'], + ).toBeFalsy(); + + // Updated schema adds 'review' field on the 'Query' type + expect( + (schemas[1].getType('Query') as GraphQLObjectType).getFields()['review'], + ).toBeTruthy(); }); describe('Supergraph SDL update failures', () => { @@ -168,7 +175,7 @@ describe('Supergraph SDL update failures', () => { gateway = new ApolloGateway({ logger, uplinkEndpoints: [mockCloudConfigUrl1], - uplinkMaxRetries: 0 + uplinkMaxRetries: 0, }); await expect( @@ -190,24 +197,23 @@ describe('Supergraph SDL update failures', () => { mockSupergraphSdlRequestIfAfter('originalId-1234').reply(500); // Spy on logger.error so we can just await once it's been called - let errorLogged: Function; - const errorLoggedPromise = new Promise((r) => (errorLogged = r)); - logger.error = jest.fn(() => errorLogged()); + const errorLoggedPromise = resolvable(); + logger.error = jest.fn(() => errorLoggedPromise.resolve()); gateway = new ApolloGateway({ logger, uplinkEndpoints: [mockCloudConfigUrl1], - uplinkMaxRetries: 0 + uplinkMaxRetries: 0, }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; await gateway.load(mockApolloConfig); await errorLoggedPromise; expect(logger.error).toHaveBeenCalledWith( - 'An error occurred while fetching your schema from Apollo: 500 Internal Server Error', + 'UplinkFetcher failed to update supergraph with the following error: An error occurred while fetching your schema from Apollo: 500 Internal Server Error', ); }); @@ -224,25 +230,22 @@ describe('Supergraph SDL update failures', () => { }); // Spy on logger.error so we can just await once it's been called - let errorLogged: Function; - const errorLoggedPromise = new Promise((r) => (errorLogged = r)); - logger.error = jest.fn(() => errorLogged()); + const errorLoggedPromise = resolvable(); + logger.error = jest.fn(() => errorLoggedPromise.resolve()); gateway = new ApolloGateway({ logger, uplinkEndpoints: [mockCloudConfigUrl1], - uplinkMaxRetries: 0 + uplinkMaxRetries: 0, }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; await gateway.load(mockApolloConfig); await errorLoggedPromise; expect(logger.error).toHaveBeenCalledWith( - 'An error occurred while fetching your schema from Apollo: ' + - '\n' + - 'Cannot query field "fail" on type "Query".', + `UplinkFetcher failed to update supergraph with the following error: An error occurred while fetching your schema from Apollo: \nCannot query field "fail" on type "Query".`, ); }); @@ -262,22 +265,21 @@ describe('Supergraph SDL update failures', () => { ); // Spy on logger.error so we can just await once it's been called - let errorLogged: Function; - const errorLoggedPromise = new Promise((r) => (errorLogged = r)); - logger.error = jest.fn(() => errorLogged()); + const errorLoggedPromise = resolvable(); + logger.error = jest.fn(() => errorLoggedPromise.resolve()); gateway = new ApolloGateway({ logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; await gateway.load(mockApolloConfig); await errorLoggedPromise; expect(logger.error).toHaveBeenCalledWith( - 'Syntax Error: Unexpected Name "Syntax".', + 'UplinkFetcher failed to update supergraph with the following error: Syntax Error: Unexpected Name "Syntax".', ); expect(gateway.schema).toBeTruthy(); }); @@ -300,8 +302,8 @@ describe('Supergraph SDL update failures', () => { logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; await expect( gateway.load(mockApolloConfig), @@ -327,25 +329,22 @@ it('Rollsback to a previous schema when triggered', async () => { ); mockSupergraphSdlRequestSuccessIfAfter('updatedId-5678'); - let firstResolve: Function; - let secondResolve: Function; - let thirdResolve: Function; - const firstSchemaChangeBlocker = new Promise((res) => (firstResolve = res)); - const secondSchemaChangeBlocker = new Promise((res) => (secondResolve = res)); - const thirdSchemaChangeBlocker = new Promise((res) => (thirdResolve = res)); + const firstSchemaChangeBlocker = resolvable(); + const secondSchemaChangeBlocker = resolvable(); + const thirdSchemaChangeBlocker = resolvable(); const onChange = jest .fn() - .mockImplementationOnce(() => firstResolve()) - .mockImplementationOnce(() => secondResolve()) - .mockImplementationOnce(() => thirdResolve()); + .mockImplementationOnce(() => firstSchemaChangeBlocker.resolve()) + .mockImplementationOnce(() => secondSchemaChangeBlocker.resolve()) + .mockImplementationOnce(() => thirdSchemaChangeBlocker.resolve()); gateway = new ApolloGateway({ logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; gateway.onSchemaChange(onChange); await gateway.load(mockApolloConfig); @@ -402,9 +401,8 @@ describe('Downstream service health checks', () => { var err = e; } - // TODO: smell that we should be awaiting something else expect(err.message).toMatchInlineSnapshot(` - "The gateway did not update its schema due to failed service health checks. The gateway will continue to operate with the previous schema and reattempt updates. The following error occurred during the health check: + "The gateway subgraphs health check failed. Updating to the provided \`supergraphSdl\` will likely result in future request failures to subgraphs. The following error occurred during the health check: [accounts]: 500: Internal Server Error" `); @@ -428,8 +426,8 @@ describe('Downstream service health checks', () => { logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; await gateway.load(mockApolloConfig); await gateway.stop(); @@ -468,7 +466,7 @@ describe('Downstream service health checks', () => { // TODO: smell that we should be awaiting something else expect(err.message).toMatchInlineSnapshot(` - "The gateway did not update its schema due to failed service health checks. The gateway will continue to operate with the previous schema and reattempt updates. The following error occurred during the health check: + "The gateway subgraphs health check failed. Updating to the provided \`supergraphSdl\` will likely result in future request failures to subgraphs. The following error occurred during the health check: [accounts]: 500: Internal Server Error" `); @@ -497,22 +495,20 @@ describe('Downstream service health checks', () => { ); mockAllServicesHealthCheckSuccess(); - let resolve1: Function; - let resolve2: Function; - const schemaChangeBlocker1 = new Promise((res) => (resolve1 = res)); - const schemaChangeBlocker2 = new Promise((res) => (resolve2 = res)); + const schemaChangeBlocker1 = resolvable(); + const schemaChangeBlocker2 = resolvable(); const onChange = jest .fn() - .mockImplementationOnce(() => resolve1()) - .mockImplementationOnce(() => resolve2()); + .mockImplementationOnce(() => schemaChangeBlocker1.resolve()) + .mockImplementationOnce(() => schemaChangeBlocker2.resolve()); gateway = new ApolloGateway({ serviceHealthCheck: true, logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; gateway.onSchemaChange(onChange); await gateway.load(mockApolloConfig); @@ -530,6 +526,10 @@ describe('Downstream service health checks', () => { }); it('Preserves original schema when health check fails', async () => { + const errorLoggedPromise = resolvable(); + const errorSpy = jest.fn(() => errorLoggedPromise.resolve()); + logger.error = errorSpy; + mockSupergraphSdlRequestSuccess(); mockAllServicesHealthCheckSuccess(); @@ -546,57 +546,16 @@ describe('Downstream service health checks', () => { mockServiceHealthCheckSuccess(reviews); mockServiceHealthCheckSuccess(documents); - let resolve: Function; - const schemaChangeBlocker = new Promise((res) => (resolve = res)); - gateway = new ApolloGateway({ serviceHealthCheck: true, logger, uplinkEndpoints: [mockCloudConfigUrl1], }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; - - // @ts-ignore for testing purposes, we'll call the original `updateSchema` - // function from our mock. The first call should mimic original behavior, - // but the second call needs to handle the PromiseRejection. Typically for tests - // like these we would leverage the `gateway.onSchemaChange` callback to drive - // the test, but in this case, that callback isn't triggered when the update - // fails (as expected) so we get creative with the second mock as seen below. - const original = gateway.updateSchema; - const mockUpdateSchema = jest - .fn() - .mockImplementationOnce(async () => { - await original.apply(gateway); - }) - .mockImplementationOnce(async () => { - // mock the first poll and handle the error which would otherwise be caught - // and logged from within the `pollServices` class method - - // This is the ideal, but our version of Jest has a bug with printing error snapshots. - // See: https://github.com/facebook/jest/pull/10217 (fixed in v26.2.0) - // expect(original.apply(gateway)).rejects.toThrowErrorMatchingInlineSnapshot(` - // The gateway did not update its schema due to failed service health checks. The gateway will continue to operate with the previous schema and reattempt updates. The following error occurred during the health check: - // [accounts]: 500: Internal Server Error" - // `); - // Instead we'll just use the regular snapshot matcher... - try { - await original.apply(gateway); - } catch (e) { - var err = e; - } - - expect(err.message).toMatchInlineSnapshot(` - "The gateway did not update its schema due to failed service health checks. The gateway will continue to operate with the previous schema and reattempt updates. The following error occurred during the health check: - [accounts]: 500: Internal Server Error" - `); - // finally resolve the promise which drives this test - resolve(); - }); - - // @ts-ignore for testing purposes, replace the `updateSchema` - // function on the gateway with our mock - gateway.updateSchema = mockUpdateSchema; + // for testing purposes, a short pollInterval is ideal so we'll override here + gateway['pollIntervalInMs'] = 100; + + const updateSpy = jest.fn(); + gateway.onSchemaLoadOrUpdate(() => updateSpy()); // load the gateway as usual await gateway.load(mockApolloConfig); @@ -605,11 +564,15 @@ describe('Downstream service health checks', () => { expect(getRootQueryFields(gateway.schema)).toContain('topReviews'); expect(getRootQueryFields(gateway.schema)).not.toContain('review'); - await schemaChangeBlocker; + await errorLoggedPromise; + expect(logger.error).toHaveBeenCalledWith( + `UplinkFetcher failed to update supergraph with the following error: The gateway subgraphs health check failed. Updating to the provided \`supergraphSdl\` will likely result in future request failures to subgraphs. The following error occurred during the health check:\n[accounts]: 500: Internal Server Error`, + ); // At this point, the mock update should have been called but the schema // should still be the original. - expect(mockUpdateSchema).toHaveBeenCalledTimes(2); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(getRootQueryFields(gateway.schema)).toContain('topReviews'); expect(getRootQueryFields(gateway.schema)).not.toContain('review'); }); diff --git a/gateway-js/src/__tests__/integration/nockMocks.ts b/gateway-js/src/__tests__/integration/nockMocks.ts index 2789825742..6c5c17dc04 100644 --- a/gateway-js/src/__tests__/integration/nockMocks.ts +++ b/gateway-js/src/__tests__/integration/nockMocks.ts @@ -1,10 +1,9 @@ import nock from 'nock'; -import { MockService } from './networkRequests.test'; import { HEALTH_CHECK_QUERY, SERVICE_DEFINITION_QUERY } from '../..'; -import { SUPERGRAPH_SDL_QUERY } from '../../loadSupergraphSdlFromStorage'; +import { SUPERGRAPH_SDL_QUERY } from '../../supergraphManagers/UplinkFetcher/loadSupergraphSdlFromStorage'; import { getTestingSupergraphSdl } from '../../__tests__/execution-utils'; import { print } from 'graphql'; -import { fixtures } from 'apollo-federation-integration-testsuite'; +import { Fixture, fixtures as testingFixtures } from 'apollo-federation-integration-testsuite'; export const graphRef = 'federated-service@current'; export const apiKey = 'service:federated-service:DD71EBbGmsuh-6suUVDwnA'; @@ -19,31 +18,39 @@ export const mockApolloConfig = { }; // Service mocks -function mockSdlQuery({ url }: MockService) { +function mockSdlQuery({ url }: Fixture) { return nock(url).post('/', { query: SERVICE_DEFINITION_QUERY, }); } -export function mockSdlQuerySuccess(service: MockService) { +export function mockSdlQuerySuccess(service: Fixture) { return mockSdlQuery(service).reply(200, { data: { _service: { sdl: print(service.typeDefs) } }, }); } -export function mockServiceHealthCheck({ url }: MockService) { +export function mockAllServicesSdlQuerySuccess( + fixtures: Fixture[] = testingFixtures, +) { + return fixtures.map((fixture) => mockSdlQuerySuccess(fixture)); +} + +export function mockServiceHealthCheck({ url }: Fixture) { return nock(url).post('/', { query: HEALTH_CHECK_QUERY, }); } -export function mockServiceHealthCheckSuccess(service: MockService) { +export function mockServiceHealthCheckSuccess(service: Fixture) { return mockServiceHealthCheck(service).reply(200, { data: { __typename: 'Query' }, }); } -export function mockAllServicesHealthCheckSuccess() { +export function mockAllServicesHealthCheckSuccess( + fixtures: Fixture[] = testingFixtures, +) { return fixtures.map((fixture) => mockServiceHealthCheck(fixture).reply(200, { data: { __typename: 'Query' }, diff --git a/gateway-js/src/config.ts b/gateway-js/src/config.ts index 4fc229cc19..10766bea7d 100644 --- a/gateway-js/src/config.ts +++ b/gateway-js/src/config.ts @@ -1,8 +1,11 @@ -import { GraphQLError, GraphQLSchema } from "graphql"; -import { HeadersInit } from "node-fetch"; +import { GraphQLError, GraphQLSchema } from 'graphql'; +import { HeadersInit } from 'node-fetch'; import { fetch } from 'apollo-server-env'; -import { GraphQLRequestContextExecutionDidStart, Logger } from "apollo-server-types"; -import { ServiceDefinition } from "@apollo/federation"; +import { + GraphQLRequestContextExecutionDidStart, + Logger, +} from 'apollo-server-types'; +import { ServiceDefinition } from '@apollo/federation'; import { GraphQLDataSource } from './datasources/types'; import { QueryPlan } from '@apollo/query-planner'; import { OperationContext } from './operationContext'; @@ -62,7 +65,7 @@ export type CompositionInfo = | ServiceDefinitionCompositionInfo | SupergraphSdlCompositionInfo; -export type Experimental_DidUpdateCompositionCallback = ( +export type Experimental_DidUpdateSupergraphCallback = ( currentConfig: CompositionInfo, previousConfig?: CompositionInfo, ) => void; @@ -80,7 +83,9 @@ export interface SupergraphSdlUpdate { supergraphSdl: string; } -export function isSupergraphSdlUpdate(update: CompositionUpdate): update is SupergraphSdlUpdate { +export function isSupergraphSdlUpdate( + update: CompositionUpdate, +): update is SupergraphSdlUpdate { return 'supergraphSdl' in update; } @@ -119,71 +124,181 @@ interface GatewayConfigBase { // experimental observability callbacks experimental_didResolveQueryPlan?: Experimental_DidResolveQueryPlanCallback; - experimental_didFailComposition?: Experimental_DidFailCompositionCallback; - experimental_didUpdateComposition?: Experimental_DidUpdateCompositionCallback; + experimental_didUpdateSupergraph?: Experimental_DidUpdateSupergraphCallback; + /** + * @deprecated use `pollIntervalInMs` instead + */ experimental_pollInterval?: number; + pollIntervalInMs?: number; experimental_approximateQueryPlanStoreMiB?: number; experimental_autoFragmentization?: boolean; fetcher?: typeof fetch; serviceHealthCheck?: boolean; } -export interface RemoteGatewayConfig extends GatewayConfigBase { +// TODO(trevor:removeServiceList) +export interface ServiceListGatewayConfig extends GatewayConfigBase { + /** + * @deprecated: use `supergraphSdl: new IntrospectAndCompose(...)` instead + */ serviceList: ServiceEndpointDefinition[]; + /** + * @deprecated: use `supergraphSdl: new IntrospectAndCompose(...)` instead + */ introspectionHeaders?: | HeadersInit - | ((service: ServiceEndpointDefinition) => Promise | HeadersInit); + | (( + service: ServiceEndpointDefinition, + ) => Promise | HeadersInit); } export interface ManagedGatewayConfig extends GatewayConfigBase { /** * This configuration option shouldn't be used unless by recommendation from * Apollo staff. + * + * @deprecated: use `uplinkEndpoints` instead + */ + schemaConfigDeliveryEndpoint?: string; + /** + * This defaults to: + * ['https://uplink.api.apollographql.com/', 'https://aws.uplink.api.apollographql.com/'] + * The first URL points to GCP, the second to AWS. This option should most likely + * be left to default unless you have a specific reason to change it. */ - schemaConfigDeliveryEndpoint?: string; // deprecated uplinkEndpoints?: string[]; uplinkMaxRetries?: number; } +// TODO(trevor:removeServiceList): migrate users to `supergraphSdl` function option interface ManuallyManagedServiceDefsGatewayConfig extends GatewayConfigBase { + /** + * @deprecated: use `supergraphSdl` instead (either as a `SupergraphSdlHook` or `SupergraphManager`) + */ experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions; } -interface ManuallyManagedSupergraphSdlGatewayConfig extends GatewayConfigBase { - experimental_updateSupergraphSdl: Experimental_UpdateSupergraphSdl +// TODO(trevor:removeServiceList): migrate users to `supergraphSdl` function option +interface ExperimentalManuallyManagedSupergraphSdlGatewayConfig + extends GatewayConfigBase { + /** + * @deprecated: use `supergraphSdl` instead (either as a `SupergraphSdlHook` or `SupergraphManager`) + */ + experimental_updateSupergraphSdl: Experimental_UpdateSupergraphSdl; +} + +export function isManuallyManagedSupergraphSdlGatewayConfig( + config: GatewayConfig, +): config is ManuallyManagedSupergraphSdlGatewayConfig { + return isSupergraphSdlHookConfig(config) || isSupergraphManagerConfig(config); +} + +export type SupergraphSdlUpdateFunction = ( + updatedSupergraphSdl: string, +) => void; + +export type SubgraphHealthCheckFunction = ( + supergraphSdl: string, +) => Promise; + +export type GetDataSourceFunction = ({ + name, + url, +}: ServiceEndpointDefinition) => GraphQLDataSource; + +export interface SupergraphSdlHookOptions { + update: SupergraphSdlUpdateFunction; + healthCheck: SubgraphHealthCheckFunction; + getDataSource: GetDataSourceFunction; +} +export interface SupergraphSdlHook { + (options: SupergraphSdlHookOptions): Promise<{ + supergraphSdl: string; + cleanup?: () => Promise; + }>; +} + +export interface SupergraphManager { + initialize: SupergraphSdlHook; +} + +type ManuallyManagedSupergraphSdlGatewayConfig = + | SupergraphSdlHookGatewayConfig + | SupergraphManagerGatewayConfig; + +export interface SupergraphSdlHookGatewayConfig extends GatewayConfigBase { + supergraphSdl: SupergraphSdlHook; +} + +export interface SupergraphManagerGatewayConfig extends GatewayConfigBase { + supergraphSdl: SupergraphManager; } type ManuallyManagedGatewayConfig = | ManuallyManagedServiceDefsGatewayConfig - | ManuallyManagedSupergraphSdlGatewayConfig; + | ExperimentalManuallyManagedSupergraphSdlGatewayConfig + | ManuallyManagedSupergraphSdlGatewayConfig + // TODO(trevor:removeServiceList + | ServiceListGatewayConfig; +// TODO(trevor:removeServiceList) interface LocalGatewayConfig extends GatewayConfigBase { + /** + * @deprecated: use `supergraphSdl: new LocalCompose(...)` instead + */ localServiceList: ServiceDefinition[]; } -interface SupergraphSdlGatewayConfig extends GatewayConfigBase { +interface StaticSupergraphSdlGatewayConfig extends GatewayConfigBase { supergraphSdl: string; } -export type StaticGatewayConfig = LocalGatewayConfig | SupergraphSdlGatewayConfig; +export type StaticGatewayConfig = + | LocalGatewayConfig + | StaticSupergraphSdlGatewayConfig; -type DynamicGatewayConfig = -| ManagedGatewayConfig -| RemoteGatewayConfig -| ManuallyManagedGatewayConfig; +export type DynamicGatewayConfig = + | ManagedGatewayConfig + | ManuallyManagedGatewayConfig; export type GatewayConfig = StaticGatewayConfig | DynamicGatewayConfig; -export function isLocalConfig(config: GatewayConfig): config is LocalGatewayConfig { +// TODO(trevor:removeServiceList) +export function isLocalConfig( + config: GatewayConfig, +): config is LocalGatewayConfig { return 'localServiceList' in config; } -export function isRemoteConfig(config: GatewayConfig): config is RemoteGatewayConfig { +// TODO(trevor:removeServiceList) +export function isServiceListConfig( + config: GatewayConfig, +): config is ServiceListGatewayConfig { return 'serviceList' in config; } -export function isSupergraphSdlConfig(config: GatewayConfig): config is SupergraphSdlGatewayConfig { - return 'supergraphSdl' in config; +export function isStaticSupergraphSdlConfig( + config: GatewayConfig, +): config is StaticSupergraphSdlGatewayConfig { + return 'supergraphSdl' in config && typeof config.supergraphSdl === 'string'; +} + +export function isSupergraphSdlHookConfig( + config: GatewayConfig, +): config is SupergraphSdlHookGatewayConfig { + return ( + 'supergraphSdl' in config && typeof config.supergraphSdl === 'function' + ); +} + +export function isSupergraphManagerConfig( + config: GatewayConfig, +): config is SupergraphManagerGatewayConfig { + return ( + 'supergraphSdl' in config && + typeof config.supergraphSdl === 'object' && + 'initialize' in config.supergraphSdl + ); } // A manually managed config means the user has provided a function which @@ -192,8 +307,11 @@ export function isManuallyManagedConfig( config: GatewayConfig, ): config is ManuallyManagedGatewayConfig { return ( + isManuallyManagedSupergraphSdlGatewayConfig(config) || 'experimental_updateServiceDefinitions' in config || - 'experimental_updateSupergraphSdl' in config + 'experimental_updateSupergraphSdl' in config || + // TODO(trevor:removeServiceList) + isServiceListConfig(config) ); } @@ -203,25 +321,16 @@ export function isManagedConfig( ): config is ManagedGatewayConfig { return ( 'schemaConfigDeliveryEndpoint' in config || - (!isRemoteConfig(config) && - !isLocalConfig(config) && - !isSupergraphSdlConfig(config) && + 'uplinkEndpoints' in config || + (!isLocalConfig(config) && + !isStaticSupergraphSdlConfig(config) && !isManuallyManagedConfig(config)) ); } // A static config is one which loads synchronously on start and never updates -export function isStaticConfig(config: GatewayConfig): config is StaticGatewayConfig { - return isLocalConfig(config) || isSupergraphSdlConfig(config); -} - -// A dynamic config is one which loads asynchronously and (can) update via polling -export function isDynamicConfig( +export function isStaticConfig( config: GatewayConfig, -): config is DynamicGatewayConfig { - return ( - isRemoteConfig(config) || - isManagedConfig(config) || - isManuallyManagedConfig(config) - ); +): config is StaticGatewayConfig { + return isLocalConfig(config) || isStaticSupergraphSdlConfig(config); } diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index fa7c6b191d..02e5b46e0b 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -16,22 +16,18 @@ import { parse, Source, } from 'graphql'; -import { - composeAndValidate, - compositionHasErrors, - ServiceDefinition, -} from '@apollo/federation'; +import { ServiceDefinition } from '@apollo/federation'; import loglevel from 'loglevel'; - import { buildOperationContext, OperationContext } from './operationContext'; import { executeQueryPlan, ServiceMap, defaultFieldResolverWithAliasSupport, } from './executeQueryPlan'; - -import { getServiceDefinitionsFromRemoteEndpoint } from './loadServicesFromRemoteEndpoint'; -import { GraphQLDataSource, GraphQLDataSourceRequestKind } from './datasources/types'; +import { + GraphQLDataSource, + GraphQLDataSourceRequestKind, +} from './datasources/types'; import { RemoteGraphQLDataSource } from './datasources/RemoteGraphQLDataSource'; import { getVariableValues } from 'graphql/execution/values'; import fetcher from 'make-fetch-happen'; @@ -47,32 +43,32 @@ import { ServiceEndpointDefinition, Experimental_DidFailCompositionCallback, Experimental_DidResolveQueryPlanCallback, - Experimental_DidUpdateCompositionCallback, + Experimental_DidUpdateSupergraphCallback, Experimental_UpdateComposition, CompositionInfo, GatewayConfig, - StaticGatewayConfig, - RemoteGatewayConfig, - ManagedGatewayConfig, isManuallyManagedConfig, isLocalConfig, - isRemoteConfig, + isServiceListConfig, isManagedConfig, - isDynamicConfig, - isStaticConfig, - CompositionMetadata, - isSupergraphSdlUpdate, - isServiceDefinitionUpdate, - ServiceDefinitionUpdate, SupergraphSdlUpdate, - CompositionUpdate, + isManuallyManagedSupergraphSdlGatewayConfig, + ManagedGatewayConfig, + isStaticSupergraphSdlConfig, + SupergraphManager, } from './config'; import { buildComposedSchema } from '@apollo/query-planner'; -import { loadSupergraphSdlFromUplinks } from './loadSupergraphSdlFromStorage'; import { SpanStatusCode } from '@opentelemetry/api'; import { OpenTelemetrySpanNames, tracer } from './utilities/opentelemetry'; import { CoreSchema } from '@apollo/core-schema'; import { featureSupport } from './core'; +import { createHash } from './utilities/createHash'; +import { + IntrospectAndCompose, + UplinkFetcher, + LegacyFetcher, + LocalCompose, +} from './supergraphManagers'; type DataSourceMap = { [serviceName: string]: { url?: string; dataSource: GraphQLDataSource }; @@ -122,12 +118,7 @@ type GatewayState = | { phase: 'loaded' } | { phase: 'stopping'; stoppingDonePromise: Promise } | { phase: 'stopped' } - | { - phase: 'waiting to poll'; - pollWaitTimer: NodeJS.Timer; - doneWaiting: () => void; - } - | { phase: 'polling'; pollingDonePromise: Promise }; + | { phase: 'updating schema' }; // We want to be compatible with `load()` as called by both AS2 and AS3, so we // define its argument types ourselves instead of relying on imports. @@ -171,9 +162,6 @@ export class ApolloGateway implements GraphQLService { coreSupergraphSdl: string; }) => void >(); - private serviceDefinitions: ServiceDefinition[] = []; - private compositionMetadata?: CompositionMetadata; - private serviceSdlCache = new Map(); private warnedStates: WarnedStates = Object.create(null); private queryPlanner?: QueryPlanner; private supergraphSdl?: string; @@ -181,30 +169,17 @@ export class ApolloGateway implements GraphQLService { private fetcher: typeof fetch; private compositionId?: string; private state: GatewayState; - private errorReportingEndpoint: string | undefined = - process.env.APOLLO_OUT_OF_BAND_REPORTER_ENDPOINT ?? undefined; // Observe query plan, service info, and operation info prior to execution. // The information made available here will give insight into the resulting // query plan and the inputs that generated it. private experimental_didResolveQueryPlan?: Experimental_DidResolveQueryPlanCallback; - // Observe composition failures and the ServiceList that caused them. This - // enables reporting any issues that occur during composition. Implementors - // will be interested in addressing these immediately. - private experimental_didFailComposition?: Experimental_DidFailCompositionCallback; - // Used to communicated composition changes, and what definitions caused - // those updates - private experimental_didUpdateComposition?: Experimental_DidUpdateCompositionCallback; - // Used for overriding the default service list fetcher. This should return - // an array of ServiceDefinition. *This function must be awaited.* - private updateServiceDefinitions: Experimental_UpdateComposition; - // how often service defs should be loaded/updated (in ms) - private experimental_pollInterval?: number; - // Configure the endpoints by which gateway will access its precomposed schema. - // * An array of URLs means use these endpoints to obtain schema, if one is unavailable then try the next. - // * `undefined` means the gateway is not using managed federation - private uplinkEndpoints?: string[]; - private uplinkMaxRetries?: number; + // Used to communicate supergraph updates + private experimental_didUpdateSupergraph?: Experimental_DidUpdateSupergraphCallback; + // how often service defs should be loaded/updated + private pollIntervalInMs?: number; + // Functions to call during gateway cleanup (when stop() is called) + private toDispose: (() => Promise)[] = []; constructor(config?: GatewayConfig) { this.config = { @@ -224,54 +199,15 @@ export class ApolloGateway implements GraphQLService { // set up experimental observability callbacks and config settings this.experimental_didResolveQueryPlan = config?.experimental_didResolveQueryPlan; - this.experimental_didFailComposition = - config?.experimental_didFailComposition; - this.experimental_didUpdateComposition = - config?.experimental_didUpdateComposition; - - this.experimental_pollInterval = config?.experimental_pollInterval; - - // 1. If config is set to a `string`, use it - // 2. If the env var is set, use that - // 3. If config is `undefined`, use the default uplink URLs - if (isManagedConfig(this.config)) { - const rawEndpointsString = process.env.APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT; - const envEndpoints = rawEndpointsString?.split(",") ?? null; - - if (this.config.schemaConfigDeliveryEndpoint && !this.config.uplinkEndpoints) { - this.uplinkEndpoints = [this.config.schemaConfigDeliveryEndpoint]; - } else { - this.uplinkEndpoints = this.config.uplinkEndpoints ?? - envEndpoints ?? [ - 'https://uplink.api.apollographql.com/', - 'https://aws.uplink.api.apollographql.com/' - ]; - } + this.experimental_didUpdateSupergraph = + config?.experimental_didUpdateSupergraph; - this.uplinkMaxRetries = this.config.uplinkMaxRetries ?? this.uplinkEndpoints.length * 3; - } + this.pollIntervalInMs = + config?.pollIntervalInMs ?? config?.experimental_pollInterval; - if (isManuallyManagedConfig(this.config)) { - // Use the provided updater function if provided by the user, else default - if ('experimental_updateSupergraphSdl' in this.config) { - this.updateServiceDefinitions = - this.config.experimental_updateSupergraphSdl; - } else if ('experimental_updateServiceDefinitions' in this.config) { - this.updateServiceDefinitions = - this.config.experimental_updateServiceDefinitions; - } else { - throw Error( - 'Programming error: unexpected manual configuration provided', - ); - } - } else { - this.updateServiceDefinitions = this.loadServiceDefinitions; - } - - if (isDynamicConfig(this.config)) { - this.issueDynamicWarningsIfApplicable(); - } + this.issueConfigurationWarningsIfApplicable(); + this.logger.debug('Gateway successfully initialized (but not yet loaded)'); this.state = { phase: 'initialized' }; } @@ -306,25 +242,26 @@ export class ApolloGateway implements GraphQLService { }); } - private issueDynamicWarningsIfApplicable() { + private issueConfigurationWarningsIfApplicable() { // Warn against a pollInterval of < 10s in managed mode and reset it to 10s if ( isManagedConfig(this.config) && - this.config.experimental_pollInterval && - this.config.experimental_pollInterval < 10000 + this.pollIntervalInMs && + this.pollIntervalInMs < 10000 ) { - this.experimental_pollInterval = 10000; + this.pollIntervalInMs = 10000; this.logger.warn( 'Polling Apollo services at a frequency of less than once per 10 ' + 'seconds (10000) is disallowed. Instead, the minimum allowed ' + 'pollInterval of 10000 will be used. Please reconfigure your ' + - 'experimental_pollInterval accordingly. If this is problematic for ' + + '`pollIntervalInMs` accordingly. If this is problematic for ' + 'your team, please contact support.', ); } // Warn against using the pollInterval and a serviceList simultaneously - if (this.config.experimental_pollInterval && isRemoteConfig(this.config)) { + // TODO(trevor:removeServiceList) + if (this.pollIntervalInMs && isServiceListConfig(this.config)) { this.logger.warn( 'Polling running services is dangerous and not recommended in production. ' + 'Polling should only be used against a registry. ' + @@ -344,12 +281,26 @@ export class ApolloGateway implements GraphQLService { 'are provided.', ); } + + if ('schemaConfigDeliveryEndpoint' in this.config) { + this.logger.warn( + 'The `schemaConfigDeliveryEndpoint` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the equivalent (array form) `uplinkEndpoints` configuration option.', + ); + } + + if ('experimental_pollInterval' in this.config) { + this.logger.warn( + 'The `experimental_pollInterval` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the equivalent `pollIntervalInMs` configuration option.', + ); + } } public async load(options?: { apollo?: ApolloConfigFromAS2Or3; engine?: GraphQLServiceEngineConfig; }) { + this.logger.debug('Loading gateway...'); + if (this.state.phase !== 'initialized') { throw Error( `ApolloGateway.load called in surprising state ${this.state.phase}`, @@ -375,40 +326,88 @@ export class ApolloGateway implements GraphQLService { }; } - // Before @apollo/gateway v0.23, ApolloGateway didn't expect stop() to be - // called after it started. The only thing that stop() did at that point was - // cancel the poll timer, and so to prevent that timer from keeping an - // otherwise-finished Node process alive, ApolloGateway unconditionally - // called unref() on that timeout. As part of making the ApolloGateway - // lifecycle more predictable and concrete (and to allow for a future where - // there are other reasons to make sure to explicitly stop your gateway), - // v0.23 tries to avoid calling unref(). - // - // Apollo Server v2.20 and newer calls gateway.stop() from its stop() - // method, so as long as you're using v2.20, ApolloGateway won't keep - // running after you stop your server, and your Node process can shut down. - // To make this change a bit less backwards-incompatible, we detect if it - // looks like you're using an older version of Apollo Server; if so, we - // still call unref(). Specifically: Apollo Server has always passed an - // options object to load(), and before v2.18 it did not pass the `apollo` - // key on it. So if we detect that particular pattern, we assume we're with - // pre-v2.18 Apollo Server and we still call unref(). So this will be a - // behavior change only for: - // - non-Apollo-Server uses of ApolloGateway (where you can add your own - // call to gateway.stop()) - // - Apollo Server v2.18 and v2.19 (where you can either do the small - // compatible upgrade or add your own call to gateway.stop()) - // - if you don't call stop() on your ApolloServer (but in that case other - // things like usage reporting will also stop shutdown, so you should fix - // that) - const unrefTimer = !!options && !options.apollo; - this.maybeWarnOnConflictingConfig(); // Handles initial assignment of `this.schema`, `this.queryPlanner` - isStaticConfig(this.config) - ? this.loadStatic(this.config) - : await this.loadDynamic(unrefTimer); + if (isStaticSupergraphSdlConfig(this.config)) { + const supergraphSdl = this.config.supergraphSdl; + await this.initializeSupergraphManager({ + initialize: async () => { + return { + supergraphSdl, + }; + }, + }); + } else if (isLocalConfig(this.config)) { + // TODO(trevor:removeServiceList) + await this.initializeSupergraphManager(new LocalCompose({ + localServiceList: this.config.localServiceList, + logger: this.logger, + })); + } else if (isManuallyManagedSupergraphSdlGatewayConfig(this.config)) { + const supergraphManager = typeof this.config.supergraphSdl === 'object' + ? this.config.supergraphSdl + : { initialize: this.config.supergraphSdl }; + await this.initializeSupergraphManager(supergraphManager); + } else if ( + 'experimental_updateServiceDefinitions' in this.config || 'experimental_updateSupergraphSdl' in this.config + ) { + const updateServiceDefinitions = + 'experimental_updateServiceDefinitions' in this.config + ? this.config.experimental_updateServiceDefinitions + : this.config.experimental_updateSupergraphSdl; + + await this.initializeSupergraphManager( + new LegacyFetcher({ + logger: this.logger, + gatewayConfig: this.config, + updateServiceDefinitions, + pollIntervalInMs: this.pollIntervalInMs, + subgraphHealthCheck: this.config.serviceHealthCheck, + }), + ); + } else if (isServiceListConfig(this.config)) { + // TODO(trevor:removeServiceList) + this.logger.warn( + 'The `serviceList` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to its replacement `IntrospectAndCompose`. More information on `IntrospectAndCompose` can be found in the documentation.', + ); + await this.initializeSupergraphManager( + new IntrospectAndCompose({ + subgraphs: this.config.serviceList, + pollIntervalInMs: this.pollIntervalInMs, + logger: this.logger, + subgraphHealthCheck: this.config.serviceHealthCheck, + introspectionHeaders: this.config.introspectionHeaders, + }), + ); + } else { + // isManagedConfig(this.config) + const canUseManagedConfig = + this.apolloConfig?.graphRef && this.apolloConfig?.keyHash; + if (!canUseManagedConfig) { + throw new Error( + 'When a manual configuration is not provided, gateway requires an Apollo ' + + 'configuration. See https://www.apollographql.com/docs/apollo-server/federation/managed-federation/ ' + + 'for more information. Manual configuration options include: ' + + '`serviceList`, `supergraphSdl`, and `experimental_updateServiceDefinitions`.', + ); + } + const uplinkEndpoints = this.getUplinkEndpoints(this.config); + + await this.initializeSupergraphManager( + new UplinkFetcher({ + graphRef: this.apolloConfig!.graphRef!, + apiKey: this.apolloConfig!.key!, + uplinkEndpoints, + maxRetries: + this.config.uplinkMaxRetries ?? uplinkEndpoints.length * 3, + subgraphHealthCheck: this.config.serviceHealthCheck, + fetcher: this.fetcher, + logger: this.logger, + pollIntervalInMs: this.pollIntervalInMs ?? 10000, + }), + ); + } const mode = isManagedConfig(this.config) ? 'managed' : 'unmanaged'; this.logger.info( @@ -425,135 +424,157 @@ export class ApolloGateway implements GraphQLService { }; } - // Synchronously load a statically configured schema, update class instance's - // schema and query planner. - private loadStatic(config: StaticGatewayConfig) { - let schema: GraphQLSchema; - let supergraphSdl: string; - try { - ({ schema, supergraphSdl } = isLocalConfig(config) - ? this.createSchemaFromServiceList(config.localServiceList) - : this.createSchemaFromSupergraphSdl(config.supergraphSdl)); - // TODO(trevor): #580 redundant parse - this.parsedSupergraphSdl = parse(supergraphSdl); - this.supergraphSdl = supergraphSdl; - this.updateWithSchemaAndNotify(schema, supergraphSdl, true); - } catch (e) { - this.state = { phase: 'failed to load' }; - throw e; - } - this.state = { phase: 'loaded' }; + private getUplinkEndpoints(config: ManagedGatewayConfig) { + /** + * Configuration priority order: + * 1. `uplinkEndpoints` configuration option + * 2. (deprecated) `schemaConfigDeliveryEndpoint` configuration option + * 3. APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT environment variable + * 4. default (GCP and AWS) + */ + const rawEndpointsString = + process.env.APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT; + const envEndpoints = rawEndpointsString?.split(',') ?? null; + return config.uplinkEndpoints ?? + (config.schemaConfigDeliveryEndpoint + ? [config.schemaConfigDeliveryEndpoint] + : null) ?? + envEndpoints ?? [ + 'https://uplink.api.apollographql.com/', + 'https://aws.uplink.api.apollographql.com/', + ]; } - // Asynchronously load a dynamically configured schema. `this.updateSchema` - // is responsible for updating the class instance's schema and query planner. - private async loadDynamic(unrefTimer: boolean) { + private getIdForSupergraphSdl(supergraphSdl: string) { + return createHash('sha256').update(supergraphSdl).digest('hex'); + } + + private async initializeSupergraphManager( + supergraphManager: T, + ) { try { - await this.updateSchema(); + const result = await supergraphManager.initialize({ + update: this.externalSupergraphUpdateCallback.bind(this), + healthCheck: this.externalSubgraphHealthCheckCallback.bind(this), + getDataSource: this.externalGetDataSourceCallback.bind(this), + }); + if (!result?.supergraphSdl) { + throw new Error( + 'Provided `supergraphSdl` function did not return an object containing a `supergraphSdl` property', + ); + } + if (result?.cleanup) { + if (typeof result.cleanup === 'function') { + this.toDispose.push(result.cleanup); + } else { + this.logger.error( + 'Provided `supergraphSdl` function returned an invalid `cleanup` property (must be a function)', + ); + } + } + + this.externalSupergraphUpdateCallback(result.supergraphSdl); } catch (e) { this.state = { phase: 'failed to load' }; + await this.performCleanupAndLogErrors(); throw e; } this.state = { phase: 'loaded' }; - if (this.shouldBeginPolling()) { - this.pollServices(unrefTimer); - } - } - - private shouldBeginPolling() { - return isManagedConfig(this.config) || this.experimental_pollInterval; - } - - private async updateSchema(): Promise { - this.logger.debug('Checking for composition updates...'); - - // This may throw, but an error here is caught and logged upstream - const result = await this.updateServiceDefinitions(this.config); - - if (isSupergraphSdlUpdate(result)) { - await this.updateWithSupergraphSdl(result); - } else if (isServiceDefinitionUpdate(result)) { - await this.updateByComposition(result); - } else { - throw new Error( - 'Programming error: unexpected result type from `updateServiceDefinitions`', - ); - } } - private async updateByComposition( - result: ServiceDefinitionUpdate, - ): Promise { - if ( - !result.serviceDefinitions || - JSON.stringify(this.serviceDefinitions) === - JSON.stringify(result.serviceDefinitions) - ) { - this.logger.debug('No change in service definitions since last check.'); - return; + /** + * @throws Error + * when called from a state other than `loaded` or `intialized` + * + * @throws Error + * when the provided supergraphSdl is invalid + */ + private externalSupergraphUpdateCallback(supergraphSdl: string) { + switch (this.state.phase) { + case 'failed to load': + throw new Error( + "Can't call `update` callback after gateway failed to load.", + ); + case 'updating schema': + throw new Error( + "Can't call `update` callback while supergraph update is in progress.", + ); + case 'stopped': + throw new Error( + "Can't call `update` callback after gateway has been stopped.", + ); + case 'stopping': + throw new Error( + "Can't call `update` callback while gateway is stopping.", + ); + case 'loaded': + case 'initialized': + // typical case + break; + default: + throw new UnreachableCaseError(this.state); } - const previousSchema = this.schema; - const previousServiceDefinitions = this.serviceDefinitions; - const previousCompositionMetadata = this.compositionMetadata; - - if (previousSchema) { - this.logger.info('New service definitions were found.'); + this.state = { phase: 'updating schema' }; + try { + this.updateWithSupergraphSdl({ + supergraphSdl, + id: this.getIdForSupergraphSdl(supergraphSdl), + }); + } finally { + // if update fails, we still want to go back to `loaded` state + this.state = { phase: 'loaded' }; } + } - await this.maybePerformServiceHealthCheck(result); - - this.compositionMetadata = result.compositionMetadata; - this.serviceDefinitions = result.serviceDefinitions; - - const { schema, supergraphSdl } = this.createSchemaFromServiceList( - result.serviceDefinitions, - ); + /** + * @throws Error + * when any subgraph fails the health check + */ + private async externalSubgraphHealthCheckCallback(supergraphSdl: string) { + const parsedSupergraphSdl = + supergraphSdl === this.supergraphSdl + ? this.parsedSupergraphSdl + : parse(supergraphSdl); + + const serviceList = this.serviceListFromSupergraphSdl(parsedSupergraphSdl!); + // Here we need to construct new datasources based on the new schema info + // so we can check the health of the services we're _updating to_. + const serviceMap = serviceList.reduce((serviceMap, serviceDef) => { + serviceMap[serviceDef.name] = { + url: serviceDef.url, + dataSource: this.createDataSource(serviceDef), + }; + return serviceMap; + }, Object.create(null) as DataSourceMap); - if (!supergraphSdl) { - this.logger.error( - "A valid schema couldn't be composed. Falling back to previous schema.", + try { + await this.serviceHealthCheck(serviceMap); + } catch (e) { + throw new Error( + 'The gateway subgraphs health check failed. Updating to the provided ' + + '`supergraphSdl` will likely result in future request failures to ' + + 'subgraphs. The following error occurred during the health check:\n' + + e.message, ); - } else { - this.updateWithSchemaAndNotify(schema, supergraphSdl); - - if (this.experimental_didUpdateComposition) { - this.experimental_didUpdateComposition( - { - serviceDefinitions: result.serviceDefinitions, - schema, - ...(this.compositionMetadata && { - compositionMetadata: this.compositionMetadata, - }), - }, - previousServiceDefinitions && - previousSchema && { - serviceDefinitions: previousServiceDefinitions, - schema: previousSchema, - ...(previousCompositionMetadata && { - compositionMetadata: previousCompositionMetadata, - }), - }, - ); - } } } - private async updateWithSupergraphSdl( - result: SupergraphSdlUpdate, - ): Promise { - if (result.id === this.compositionId) { - this.logger.debug('No change in composition since last check.'); - return; - } + private externalGetDataSourceCallback({ + name, + url, + }: ServiceEndpointDefinition) { + return this.getOrCreateDataSource({ name, url }); + } + private updateWithSupergraphSdl({ supergraphSdl, id }: SupergraphSdlUpdate) { // TODO(trevor): #580 redundant parse // This may throw, so we'll calculate early (specifically before making any updates) // In the case that it throws, the gateway will: // * on initial load, throw the error // * on update, log the error and don't update - const parsedSupergraphSdl = parse(result.supergraphSdl); + const parsedSupergraphSdl = parse(supergraphSdl); const previousSchema = this.schema; const previousSupergraphSdl = this.parsedSupergraphSdl; @@ -563,28 +584,25 @@ export class ApolloGateway implements GraphQLService { this.logger.info('Updated Supergraph SDL was found.'); } - await this.maybePerformServiceHealthCheck(result); - - this.compositionId = result.id; - this.supergraphSdl = result.supergraphSdl; + this.compositionId = id; + this.supergraphSdl = supergraphSdl; this.parsedSupergraphSdl = parsedSupergraphSdl; - const { schema, supergraphSdl } = this.createSchemaFromSupergraphSdl( - result.supergraphSdl, - ); + const { schema, supergraphSdl: generatedSupergraphSdl } = + this.createSchemaFromSupergraphSdl(supergraphSdl); - if (!supergraphSdl) { + if (!generatedSupergraphSdl) { this.logger.error( "A valid schema couldn't be composed. Falling back to previous schema.", ); } else { - this.updateWithSchemaAndNotify(schema, supergraphSdl); + this.updateWithSchemaAndNotify(schema, generatedSupergraphSdl); - if (this.experimental_didUpdateComposition) { - this.experimental_didUpdateComposition( + if (this.experimental_didUpdateSupergraph) { + this.experimental_didUpdateSupergraph( { - compositionId: result.id, - supergraphSdl: result.supergraphSdl, + compositionId: id, + supergraphSdl, schema, }, previousCompositionId && previousSupergraphSdl && previousSchema @@ -644,40 +662,6 @@ export class ApolloGateway implements GraphQLService { }); } - private async maybePerformServiceHealthCheck(update: CompositionUpdate) { - // Run service health checks before we commit and update the new schema. - // This is the last chance to bail out of a schema update. - if (this.config.serviceHealthCheck) { - const serviceList = isSupergraphSdlUpdate(update) - ? // TODO(trevor): #580 redundant parse - // Parsing could technically fail and throw here, but parseability has - // already been confirmed slightly earlier in the code path - this.serviceListFromSupergraphSdl(parse(update.supergraphSdl)) - : // Existence of this is determined in advance with an early return otherwise - update.serviceDefinitions!; - // Here we need to construct new datasources based on the new schema info - // so we can check the health of the services we're _updating to_. - const serviceMap = serviceList.reduce((serviceMap, serviceDef) => { - serviceMap[serviceDef.name] = { - url: serviceDef.url, - dataSource: this.createDataSource(serviceDef), - }; - return serviceMap; - }, Object.create(null) as DataSourceMap); - - try { - await this.serviceHealthCheck(serviceMap); - } catch (e) { - throw new Error( - 'The gateway did not update its schema due to failed service health checks. ' + - 'The gateway will continue to operate with the previous schema and reattempt updates. ' + - 'The following error occurred during the health check:\n' + - e.message, - ); - } - } - } - /** * This can be used without an argument in order to perform an ad-hoc health check * of the downstream services like so: @@ -710,51 +694,6 @@ export class ApolloGateway implements GraphQLService { ); } - private createSchemaFromServiceList(serviceList: ServiceDefinition[]) { - this.logger.debug( - `Composing schema from service list: \n${serviceList - .map(({ name, url }) => ` ${url || 'local'}: ${name}`) - .join('\n')}`, - ); - - const compositionResult = composeAndValidate(serviceList); - - if (compositionHasErrors(compositionResult)) { - const { errors } = compositionResult; - if (this.experimental_didFailComposition) { - this.experimental_didFailComposition({ - errors, - serviceList, - ...(this.compositionMetadata && { - compositionMetadata: this.compositionMetadata, - }), - }); - } - throw Error( - "A valid schema couldn't be composed. The following composition errors were found:\n" + - errors.map((e) => '\t' + e.message).join('\n'), - ); - } else { - const { supergraphSdl } = compositionResult; - this.createServices(serviceList); - - const schema = buildComposedSchema(parse(supergraphSdl)); - - this.logger.debug('Schema loaded and ready for execution'); - - // This is a workaround for automatic wrapping of all fields, which Apollo - // Server does in the case of implementing resolver wrapping for plugins. - // Here we wrap all fields with support for resolving aliases as part of the - // root value which happens because aliases are resolved by sub services and - // the shape of the root value already contains the aliased fields as - // responseNames - return { - schema: wrapSchemaWithAliasResolver(schema), - supergraphSdl, - }; - } - } - private serviceListFromSupergraphSdl( supergraphSdl: DocumentNode, ): Omit[] { @@ -819,93 +758,16 @@ export class ApolloGateway implements GraphQLService { }; } - // This function waits an appropriate amount, updates composition, and calls itself - // again. Note that it is an async function whose Promise is not actually awaited; - // it should never throw itself other than due to a bug in its state machine. - private async pollServices(unrefTimer: boolean) { - switch (this.state.phase) { - case 'stopping': - case 'stopped': - case 'failed to load': - return; - case 'initialized': - throw Error('pollServices should not be called before load!'); - case 'polling': - throw Error( - 'pollServices should not be called while in the middle of polling!', - ); - case 'waiting to poll': - throw Error( - 'pollServices should not be called while already waiting to poll!', - ); - case 'loaded': - // This is the normal case. - break; - default: - throw new UnreachableCaseError(this.state); - } - - // Transition into 'waiting to poll' and set a timer. The timer resolves the - // Promise we're awaiting here; note that calling stop() also can resolve - // that Promise. - await new Promise((doneWaiting) => { - this.state = { - phase: 'waiting to poll', - doneWaiting, - pollWaitTimer: setTimeout(() => { - // Note that we might be in 'stopped', in which case we just do - // nothing. - if (this.state.phase == 'waiting to poll') { - this.state.doneWaiting(); - } - }, this.experimental_pollInterval || 10000), - }; - if (unrefTimer) { - this.state.pollWaitTimer.unref(); - } - }); - - // If we've been stopped, then we're done. The cast here is because TS - // doesn't understand that this.state can change during the await - // (https://github.com/microsoft/TypeScript/issues/9998). - if ((this.state as GatewayState).phase !== 'waiting to poll') { - return; - } - - let pollingDone: () => void; - this.state = { - phase: 'polling', - pollingDonePromise: new Promise((res) => { - pollingDone = res; - }), - }; - - try { - await this.updateSchema(); - } catch (err) { - this.logger.error((err && err.message) || err); - } - - if (this.state.phase === 'polling') { - // If we weren't stopped, we should transition back to the initial 'loading' state and trigger - // another call to itself. (Do that in a setImmediate to avoid unbounded stack sizes.) - this.state = { phase: 'loaded' }; - setImmediate(() => this.pollServices(unrefTimer)); - } - - // Whether we were stopped or not, let any concurrent stop() call finish. - pollingDone!(); - } - - private createAndCacheDataSource( + private getOrCreateDataSource( serviceDef: ServiceEndpointDefinition, ): GraphQLDataSource { // If the DataSource has already been created, early return if ( this.serviceMap[serviceDef.name] && serviceDef.url === this.serviceMap[serviceDef.name].url - ) + ) { return this.serviceMap[serviceDef.name].dataSource; + } const dataSource = this.createDataSource(serviceDef); @@ -933,57 +795,8 @@ export class ApolloGateway implements GraphQLService { private createServices(services: ServiceEndpointDefinition[]) { for (const serviceDef of services) { - this.createAndCacheDataSource(serviceDef); - } - } - - protected async loadServiceDefinitions( - config: RemoteGatewayConfig | ManagedGatewayConfig, - ): Promise { - if (isRemoteConfig(config)) { - const serviceList = config.serviceList.map((serviceDefinition) => ({ - ...serviceDefinition, - dataSource: this.createAndCacheDataSource(serviceDefinition), - })); - - return getServiceDefinitionsFromRemoteEndpoint({ - serviceList, - async getServiceIntrospectionHeaders(service) { - return typeof config.introspectionHeaders === 'function' - ? await config.introspectionHeaders(service) - : config.introspectionHeaders; - }, - serviceSdlCache: this.serviceSdlCache, - }); - } - - const canUseManagedConfig = - this.apolloConfig?.graphRef && this.apolloConfig?.keyHash; - if (!canUseManagedConfig) { - throw new Error( - 'When a manual configuration is not provided, gateway requires an Apollo ' + - 'configuration. See https://www.apollographql.com/docs/apollo-server/federation/managed-federation/ ' + - 'for more information. Manual configuration options include: ' + - '`serviceList`, `supergraphSdl`, and `experimental_updateServiceDefinitions`.', - ); + this.getOrCreateDataSource(serviceDef); } - - const result = await loadSupergraphSdlFromUplinks({ - graphRef: this.apolloConfig!.graphRef!, - apiKey: this.apolloConfig!.key!, - endpoints: this.uplinkEndpoints!, - errorReportingEndpoint: this.errorReportingEndpoint, - fetcher: this.fetcher, - compositionId: this.compositionId ?? null, - maxRetries: this.uplinkMaxRetries!, - }); - - return ( - result ?? { - id: this.compositionId!, - supergraphSdl: this.supergraphSdl!, - } - ); } private maybeWarnOnConflictingConfig() { @@ -1202,9 +1015,24 @@ export class ApolloGateway implements GraphQLService { }); } - // Stops all processes involved with the gateway (for now, just background - // schema polling). Can be called multiple times safely. Once it (async) - // returns, all gateway background activity will be finished. + private async performCleanupAndLogErrors() { + if (this.toDispose.length === 0) return; + + await Promise.all( + this.toDispose.map((p) => + p().catch((e) => { + this.logger.error( + 'Error occured while calling user provided `cleanup` function: ' + + (e.message ?? e), + ); + }), + ), + ); + this.toDispose = []; + } + + // Stops all processes involved with the gateway. Can be called multiple times + // safely. Once it (async) returns, all gateway background activity will be finished. public async stop() { switch (this.state.phase) { case 'initialized': @@ -1227,40 +1055,31 @@ export class ApolloGateway implements GraphQLService { } return; case 'loaded': - this.state = { phase: 'stopped' }; // nothing to do (we're not polling) - return; - case 'waiting to poll': { - // If we're waiting to poll, we can synchronously transition to fully stopped. - // We will terminate the current pollServices call and it will succeed quickly. - const doneWaiting = this.state.doneWaiting; - clearTimeout(this.state.pollWaitTimer); - this.state = { phase: 'stopped' }; - doneWaiting(); - return; - } - case 'polling': { - // We're in the middle of running updateSchema. We need to go into 'stopping' - // mode and let this run complete. First we set things up so that any concurrent - // calls to stop() will wait until we let them finish. (Those concurrent calls shouldn't - // just wait on pollingDonePromise themselves because we want to make sure we fully - // transition to state='stopped' before the other call returns.) - const pollingDonePromise = this.state.pollingDonePromise; - let stoppingDone: () => void; + const stoppingDonePromise = this.performCleanupAndLogErrors(); this.state = { phase: 'stopping', - stoppingDonePromise: new Promise((res) => { - stoppingDone = res; - }), + stoppingDonePromise, }; - await pollingDonePromise; + await stoppingDonePromise; this.state = { phase: 'stopped' }; - stoppingDone!(); return; + case 'updating schema': { + throw Error( + '`ApolloGateway.stop` shouldn\'t be called from inside a schema change listener', + ); } default: throw new UnreachableCaseError(this.state); } } + + public __testing() { + return { + state: this.state, + compositionId: this.compositionId, + supergraphSdl: this.supergraphSdl, + }; + } } ApolloGateway.prototype.onSchemaChange = deprecate( @@ -1307,11 +1126,21 @@ export { ServiceMap, Experimental_DidFailCompositionCallback, Experimental_DidResolveQueryPlanCallback, - Experimental_DidUpdateCompositionCallback, + Experimental_DidUpdateSupergraphCallback, Experimental_UpdateComposition, GatewayConfig, ServiceEndpointDefinition, CompositionInfo, + IntrospectAndCompose, + LocalCompose, }; export * from './datasources'; + +export { + SupergraphSdlUpdateFunction, + SubgraphHealthCheckFunction, + GetDataSourceFunction, + SupergraphSdlHook, + SupergraphManager +} from './config'; diff --git a/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/IntrospectAndCompose.test.ts b/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/IntrospectAndCompose.test.ts new file mode 100644 index 0000000000..e135c660fa --- /dev/null +++ b/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/IntrospectAndCompose.test.ts @@ -0,0 +1,370 @@ +import nock from 'nock'; +import { + fixtures, + fixturesWithUpdate, +} from 'apollo-federation-integration-testsuite'; +import { nockBeforeEach, nockAfterEach } from '../../../__tests__/nockAssertions'; +import { RemoteGraphQLDataSource, ServiceEndpointDefinition } from '../../..'; +import { IntrospectAndCompose } from '..'; +import { mockAllServicesSdlQuerySuccess } from '../../../__tests__/integration/nockMocks'; +import { getTestingSupergraphSdl, wait } from '../../../__tests__/execution-utils'; +import resolvable from '@josephg/resolvable'; +import { Logger } from 'apollo-server-types'; + +describe('IntrospectAndCompose', () => { + beforeEach(nockBeforeEach); + afterEach(nockAfterEach); + + it('constructs', () => { + expect( + () => + new IntrospectAndCompose({ + subgraphs: fixtures, + }), + ).not.toThrow(); + }); + + it('has an `initialize` property which is callable (simulating the gateway calling it)', async () => { + mockAllServicesSdlQuerySuccess(); + const instance = new IntrospectAndCompose({ subgraphs: fixtures }); + await expect( + instance.initialize({ + update() {}, + async healthCheck() {}, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ url }); + }, + }), + ).resolves.toBeTruthy(); + }); + + it('uses `GraphQLDataSource`s provided by the `buildService` function', async () => { + mockAllServicesSdlQuerySuccess(); + + const processSpies: jest.SpyInstance[] = []; + function getDataSourceSpy(definition: ServiceEndpointDefinition) { + const datasource = new RemoteGraphQLDataSource({ + url: definition.url, + }); + const processSpy = jest.spyOn(datasource, 'process'); + processSpies.push(processSpy); + return datasource; + } + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + }); + + await instance.initialize({ + update() {}, + async healthCheck() {}, + getDataSource: getDataSourceSpy, + }); + + expect(processSpies.length).toBe(fixtures.length); + for (const processSpy of processSpies) { + expect(processSpy).toHaveBeenCalledTimes(1); + } + }); + + it('polls services when a `pollInterval` is set and stops when `cleanup` is called', async () => { + // This is mocked 4 times to include the initial load (followed by 3 polls) + // We need to alternate schemas, else the update will be ignored + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(fixturesWithUpdate); + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(fixturesWithUpdate); + + const p1 = resolvable(); + const p2 = resolvable(); + const p3 = resolvable(); + + // `update` (below) is called each time we poll (and there's an update to + // the supergraph), so this is a reasonable hook into "when" the poll + // happens and drives this test cleanly with `Promise`s. + const updateSpy = jest + .fn() + .mockImplementationOnce(() => p1.resolve()) + .mockImplementationOnce(() => p2.resolve()) + .mockImplementationOnce(() => p3.resolve()); + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + pollIntervalInMs: 10, + }); + + const { cleanup } = await instance.initialize({ + update(supergraphSdl) { + updateSpy(supergraphSdl); + }, + async healthCheck() {}, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ url }); + }, + }); + + await Promise.all([p1, p2, p3]); + + expect(updateSpy).toHaveBeenCalledTimes(3); + + // stop polling + await cleanup!(); + + expect(updateSpy).toHaveBeenCalledTimes(3); + + // ensure we cancelled the timer + // @ts-ignore + expect(instance.timerRef).toBe(null); + }); + + // TODO: useFakeTimers (though I'm struggling to get this to work as expected) + it("doesn't call `update` when there's no change to the supergraph", async () => { + const fetcher = + jest.requireActual( + 'apollo-server-env', + ).fetch; + + // mock for initial load and a few polls against an unchanging schema + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(); + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + pollIntervalInMs: 100, + }); + + const updateSpy = jest.fn(); + const { cleanup } = await instance.initialize({ + update(supergraphSdl) { + updateSpy(supergraphSdl); + }, + async healthCheck() {}, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ + url, + fetcher, + }); + }, + }); + + // let the instance poll through all the active mocks + // wouldn't need to do this if I could get fakeTimers working as expected + while (nock.activeMocks().length > 0) { + await wait(0); + } + + await cleanup!(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('issues subgraph health checks when enabled (and polling)', async () => { + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(fixturesWithUpdate); + + const healthCheckPromiseOnLoad = resolvable(); + const healthCheckPromiseOnUpdate = resolvable(); + + const healthCheckSpy = jest + .fn() + .mockImplementationOnce(() => healthCheckPromiseOnLoad.resolve()) + .mockImplementationOnce(() => healthCheckPromiseOnUpdate.resolve()); + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + pollIntervalInMs: 10, + subgraphHealthCheck: true, + }); + + const { cleanup } = await instance.initialize({ + update() {}, + async healthCheck(supergraphSdl) { + healthCheckSpy(supergraphSdl); + }, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ url }); + }, + }); + + await Promise.all([ + healthCheckPromiseOnLoad, + healthCheckPromiseOnUpdate, + ]); + + expect(healthCheckSpy).toHaveBeenNthCalledWith( + 1, + getTestingSupergraphSdl(fixtures), + ); + expect(healthCheckSpy).toHaveBeenNthCalledWith( + 2, + getTestingSupergraphSdl(fixturesWithUpdate), + ); + + // stop polling + await cleanup!(); + }); + + describe('errors', () => { + it('logs an error when `update` function throws', async () => { + const errorLoggedPromise = resolvable(); + + const errorSpy = jest.fn(() => { + errorLoggedPromise.resolve(); + }); + const logger: Logger = { + error: errorSpy, + debug() {}, + info() {}, + warn() {}, + }; + + // mock successful initial load + mockAllServicesSdlQuerySuccess(); + + // mock first update + mockAllServicesSdlQuerySuccess(fixturesWithUpdate); + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + pollIntervalInMs: 1000, + logger, + }); + + const thrownErrorMessage = 'invalid supergraph'; + // simulate gateway throwing an error when `update` is called + const updateSpy = jest.fn().mockImplementationOnce(() => { + throw new Error(thrownErrorMessage); + }); + + const { cleanup } = await instance.initialize({ + update: updateSpy, + async healthCheck() {}, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ url }); + }, + }); + + await errorLoggedPromise; + // stop polling + await cleanup!(); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + `IntrospectAndCompose failed to update supergraph with the following error: ${thrownErrorMessage}`, + ); + }); + + it('fails to load when `healthCheck` function throws on startup', async () => { + mockAllServicesSdlQuerySuccess(); + + const expectedErrorMsg = 'error reaching subgraph'; + const errorLoggedPromise = resolvable(); + const errorSpy = jest.fn(() => { + errorLoggedPromise.resolve(); + }); + const logger: Logger = { + error: errorSpy, + debug() {}, + info() {}, + warn() {}, + }; + + const updateSpy = jest.fn(); + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + pollIntervalInMs: 10, + subgraphHealthCheck: true, + logger, + }); + + await expect( + instance.initialize({ + update() { + updateSpy(); + }, + async healthCheck() { + throw new Error(expectedErrorMsg); + }, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ url }); + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"error reaching subgraph"`); + + await errorLoggedPromise; + + expect(errorSpy).toHaveBeenCalledWith( + `IntrospectAndCompose failed to update supergraph with the following error: ${expectedErrorMsg}`, + ); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('does not attempt to update when `healthCheck` function throws', async () => { + mockAllServicesSdlQuerySuccess(); + mockAllServicesSdlQuerySuccess(fixturesWithUpdate); + + const expectedErrorMsg = 'error reaching subgraph'; + const errorLoggedPromise = resolvable(); + const errorSpy = jest.fn(() => { + errorLoggedPromise.resolve(); + }); + const logger: Logger = { + error: errorSpy, + debug() {}, + info() {}, + warn() {}, + }; + + const healthCheckPromiseOnLoad = resolvable(); + const healthCheckPromiseOnUpdate = resolvable(); + const healthCheckSpyWhichEventuallyThrows = jest + .fn() + .mockImplementationOnce(() => healthCheckPromiseOnLoad.resolve()) + .mockImplementationOnce(() => { + healthCheckPromiseOnUpdate.resolve(); + throw new Error(expectedErrorMsg); + }); + + const updateSpy = jest.fn(); + + const instance = new IntrospectAndCompose({ + subgraphs: fixtures, + pollIntervalInMs: 10, + subgraphHealthCheck: true, + logger, + }); + + const { cleanup } = await instance.initialize({ + update() { + updateSpy(); + }, + async healthCheck(supergraphSdl) { + healthCheckSpyWhichEventuallyThrows(supergraphSdl); + }, + getDataSource({ url }) { + return new RemoteGraphQLDataSource({ url }); + }, + }); + + await Promise.all([ + healthCheckPromiseOnLoad, + healthCheckPromiseOnUpdate, + errorLoggedPromise, + ]); + + expect(errorSpy).toHaveBeenCalledWith( + `IntrospectAndCompose failed to update supergraph with the following error: ${expectedErrorMsg}`, + ); + expect(healthCheckSpyWhichEventuallyThrows).toHaveBeenCalledTimes(2); + // update isn't called on load so this shouldn't be called even once + expect(updateSpy).not.toHaveBeenCalled(); + + // stop polling + await cleanup!(); + }); + }); +}); diff --git a/gateway-js/src/__tests__/loadServicesFromRemoteEndpoint.test.ts b/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/loadServicesFromRemoteEndpoint.test.ts similarity index 81% rename from gateway-js/src/__tests__/loadServicesFromRemoteEndpoint.test.ts rename to gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/loadServicesFromRemoteEndpoint.test.ts index c5d189ebd9..6f9fceb0a4 100644 --- a/gateway-js/src/__tests__/loadServicesFromRemoteEndpoint.test.ts +++ b/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/loadServicesFromRemoteEndpoint.test.ts @@ -1,13 +1,13 @@ -import { getServiceDefinitionsFromRemoteEndpoint } from '../loadServicesFromRemoteEndpoint'; -import { RemoteGraphQLDataSource } from '../datasources'; +import { loadServicesFromRemoteEndpoint } from '../loadServicesFromRemoteEndpoint'; +import { RemoteGraphQLDataSource } from '../../../datasources'; -describe('getServiceDefinitionsFromRemoteEndpoint', () => { +describe('loadServicesFromRemoteEndpoint', () => { it('errors when no URL was specified', async () => { const serviceSdlCache = new Map(); const dataSource = new RemoteGraphQLDataSource({ url: '' }); const serviceList = [{ name: 'test', dataSource }]; await expect( - getServiceDefinitionsFromRemoteEndpoint({ + loadServicesFromRemoteEndpoint({ serviceList, serviceSdlCache, getServiceIntrospectionHeaders: async () => ({}) @@ -28,7 +28,7 @@ describe('getServiceDefinitionsFromRemoteEndpoint', () => { // of `EAI_AGAIN` or `ENOTFOUND`. This `toThrowError` uses a Regex // to match either case. await expect( - getServiceDefinitionsFromRemoteEndpoint({ + loadServicesFromRemoteEndpoint({ serviceList, serviceSdlCache, getServiceIntrospectionHeaders: async () => ({}), diff --git a/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/tsconfig.json b/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/tsconfig.json new file mode 100644 index 0000000000..12ae429d6b --- /dev/null +++ b/gateway-js/src/supergraphManagers/IntrospectAndCompose/__tests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.test", + "include": ["**/*"], + "references": [ + { "path": "../../../../" }, + { "path": "../../../../../federation-integration-testsuite-js" }, + ] +} diff --git a/gateway-js/src/supergraphManagers/IntrospectAndCompose/index.ts b/gateway-js/src/supergraphManagers/IntrospectAndCompose/index.ts new file mode 100644 index 0000000000..3b1f4bc0d2 --- /dev/null +++ b/gateway-js/src/supergraphManagers/IntrospectAndCompose/index.ts @@ -0,0 +1,163 @@ +import { + composeAndValidate, + compositionHasErrors, + ServiceDefinition, +} from '@apollo/federation'; +import { Logger } from 'apollo-server-types'; +import { HeadersInit } from 'node-fetch'; +import resolvable from '@josephg/resolvable'; +import { + ServiceEndpointDefinition, + SupergraphSdlUpdateFunction, + SubgraphHealthCheckFunction, +} from '../..'; +import { + loadServicesFromRemoteEndpoint, + Service, +} from './loadServicesFromRemoteEndpoint'; +import { SupergraphManager, SupergraphSdlHookOptions } from '../../config'; + +export interface IntrospectAndComposeOptions { + subgraphs: ServiceEndpointDefinition[]; + introspectionHeaders?: + | HeadersInit + | (( + service: ServiceEndpointDefinition, + ) => Promise | HeadersInit); + pollIntervalInMs?: number; + logger?: Logger; + subgraphHealthCheck?: boolean; +} + +type State = + | { phase: 'initialized' } + | { phase: 'polling'; pollingPromise?: Promise } + | { phase: 'stopped' }; + +export class IntrospectAndCompose implements SupergraphManager { + private config: IntrospectAndComposeOptions; + private update?: SupergraphSdlUpdateFunction; + private healthCheck?: SubgraphHealthCheckFunction; + private subgraphs?: Service[]; + private serviceSdlCache: Map = new Map(); + private timerRef: NodeJS.Timeout | null = null; + private state: State; + + constructor(options: IntrospectAndComposeOptions) { + this.config = options; + this.state = { phase: 'initialized' }; + } + + public async initialize({ update, getDataSource, healthCheck }: SupergraphSdlHookOptions) { + this.update = update; + + if (this.config.subgraphHealthCheck) { + this.healthCheck = healthCheck; + } + + this.subgraphs = this.config.subgraphs.map((subgraph) => ({ + ...subgraph, + dataSource: getDataSource(subgraph), + })); + + let initialSupergraphSdl: string | null = null; + try { + initialSupergraphSdl = await this.updateSupergraphSdl(); + } catch (e) { + this.logUpdateFailure(e); + throw e; + } + + // Start polling after we resolve the first supergraph + if (this.config.pollIntervalInMs) { + this.beginPolling(); + } + + return { + // on init, this supergraphSdl should never actually be `null`. + // `this.updateSupergraphSdl()` will only return null if the schema hasn't + // changed over the course of an _update_. + supergraphSdl: initialSupergraphSdl!, + cleanup: async () => { + if (this.state.phase === 'polling') { + await this.state.pollingPromise; + } + this.state = { phase: 'stopped' }; + if (this.timerRef) { + clearTimeout(this.timerRef); + this.timerRef = null; + } + }, + }; + } + + private async updateSupergraphSdl() { + const result = await loadServicesFromRemoteEndpoint({ + serviceList: this.subgraphs!, + getServiceIntrospectionHeaders: async (service) => { + return typeof this.config.introspectionHeaders === 'function' + ? await this.config.introspectionHeaders(service) + : this.config.introspectionHeaders; + }, + serviceSdlCache: this.serviceSdlCache, + }); + + if (!result.isNewSchema) { + return null; + } + + const supergraphSdl = this.createSupergraphFromSubgraphList(result.serviceDefinitions!); + // the healthCheck fn is only assigned if it's enabled in the config + await this.healthCheck?.(supergraphSdl); + + return supergraphSdl; + } + + private createSupergraphFromSubgraphList(subgraphs: ServiceDefinition[]) { + const compositionResult = composeAndValidate(subgraphs); + + if (compositionHasErrors(compositionResult)) { + const { errors } = compositionResult; + throw Error( + "A valid schema couldn't be composed. The following composition errors were found:\n" + + errors.map((e) => '\t' + e.message).join('\n'), + ); + } else { + const { supergraphSdl } = compositionResult; + return supergraphSdl; + } + } + + private beginPolling() { + this.state = { phase: 'polling' }; + this.poll(); + } + + private poll() { + this.timerRef = setTimeout(async () => { + if (this.state.phase === 'polling') { + const pollingPromise = resolvable(); + + this.state.pollingPromise = pollingPromise; + try { + const maybeNewSupergraphSdl = await this.updateSupergraphSdl(); + if (maybeNewSupergraphSdl) { + this.update?.(maybeNewSupergraphSdl); + } + } catch (e) { + this.logUpdateFailure(e); + } + pollingPromise.resolve(); + } + + this.poll(); + }, this.config.pollIntervalInMs!); + } + + private logUpdateFailure(e: any) { + this.config.logger?.error( + 'IntrospectAndCompose failed to update supergraph with the following error: ' + + (e.message ?? e), + ); + } +} diff --git a/gateway-js/src/loadServicesFromRemoteEndpoint.ts b/gateway-js/src/supergraphManagers/IntrospectAndCompose/loadServicesFromRemoteEndpoint.ts similarity index 88% rename from gateway-js/src/loadServicesFromRemoteEndpoint.ts rename to gateway-js/src/supergraphManagers/IntrospectAndCompose/loadServicesFromRemoteEndpoint.ts index 0936e75128..4f946add46 100644 --- a/gateway-js/src/loadServicesFromRemoteEndpoint.ts +++ b/gateway-js/src/supergraphManagers/IntrospectAndCompose/loadServicesFromRemoteEndpoint.ts @@ -1,16 +1,16 @@ import { GraphQLRequest } from 'apollo-server-types'; import { parse } from 'graphql'; import { Headers, HeadersInit } from 'node-fetch'; -import { GraphQLDataSource, GraphQLDataSourceRequestKind } from './datasources/types'; -import { SERVICE_DEFINITION_QUERY } from './'; -import { CompositionUpdate, ServiceEndpointDefinition } from './config'; +import { GraphQLDataSource, GraphQLDataSourceRequestKind } from '../../datasources/types'; +import { SERVICE_DEFINITION_QUERY } from '../..'; +import { ServiceDefinitionUpdate, ServiceEndpointDefinition } from '../../config'; import { ServiceDefinition } from '@apollo/federation'; -type Service = ServiceEndpointDefinition & { +export type Service = ServiceEndpointDefinition & { dataSource: GraphQLDataSource; }; -export async function getServiceDefinitionsFromRemoteEndpoint({ +export async function loadServicesFromRemoteEndpoint({ serviceList, getServiceIntrospectionHeaders, serviceSdlCache, @@ -20,7 +20,7 @@ export async function getServiceDefinitionsFromRemoteEndpoint({ service: ServiceEndpointDefinition, ) => Promise; serviceSdlCache: Map; -}): Promise { +}): Promise { if (!serviceList || !serviceList.length) { throw new Error( 'Tried to load services from remote endpoints but none provided', diff --git a/gateway-js/src/supergraphManagers/LegacyFetcher/index.ts b/gateway-js/src/supergraphManagers/LegacyFetcher/index.ts new file mode 100644 index 0000000000..cd7c1cf9c0 --- /dev/null +++ b/gateway-js/src/supergraphManagers/LegacyFetcher/index.ts @@ -0,0 +1,229 @@ +/** + * Similar in concept to `IntrospectAndCompose`, but this handles + * the `experimental_updateComposition` and `experimental_updateSupergraphSdl` + * configuration options of the gateway and will be removed in a future release + * along with those options. + */ +import { Logger } from 'apollo-server-types'; +import resolvable from '@josephg/resolvable'; +import { + SupergraphManager, + SupergraphSdlHookOptions, + DynamicGatewayConfig, + isSupergraphSdlUpdate, + isServiceDefinitionUpdate, + ServiceDefinitionUpdate, + GetDataSourceFunction, +} from '../../config'; +import { + Experimental_UpdateComposition, + SubgraphHealthCheckFunction, + SupergraphSdlUpdateFunction, +} from '../..'; +import { + composeAndValidate, + compositionHasErrors, + ServiceDefinition, +} from '@apollo/federation'; + +export interface LegacyFetcherOptions { + pollIntervalInMs?: number; + logger?: Logger; + subgraphHealthCheck?: boolean; + updateServiceDefinitions: Experimental_UpdateComposition; + gatewayConfig: DynamicGatewayConfig; +} + +type State = + | { phase: 'initialized' } + | { phase: 'polling'; pollingPromise?: Promise } + | { phase: 'stopped' }; + +export class LegacyFetcher implements SupergraphManager { + private config: LegacyFetcherOptions; + private update?: SupergraphSdlUpdateFunction; + private healthCheck?: SubgraphHealthCheckFunction; + private getDataSource?: GetDataSourceFunction; + private timerRef: NodeJS.Timeout | null = null; + private state: State; + private compositionId?: string; + private serviceDefinitions?: ServiceDefinition[]; + + constructor(options: LegacyFetcherOptions) { + this.config = options; + this.state = { phase: 'initialized' }; + this.issueDeprecationWarnings(); + } + + private issueDeprecationWarnings() { + if ('experimental_updateSupergraphSdl' in this.config.gatewayConfig) { + this.config.logger?.warn( + 'The `experimental_updateSupergraphSdl` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the function form of the `supergraphSdl` configuration option.', + ); + } + + if ('experimental_updateServiceDefinitions' in this.config.gatewayConfig) { + this.config.logger?.warn( + 'The `experimental_updateServiceDefinitions` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the function form of the `supergraphSdl` configuration option.', + ); + } + } + + public async initialize({ + update, + healthCheck, + getDataSource, + }: SupergraphSdlHookOptions) { + this.update = update; + this.getDataSource = getDataSource; + + if (this.config.subgraphHealthCheck) { + this.healthCheck = healthCheck; + } + + let initialSupergraphSdl: string | null = null; + try { + initialSupergraphSdl = await this.updateSupergraphSdl(); + } catch (e) { + this.logUpdateFailure(e); + throw e; + } + + // Start polling after we resolve the first supergraph + if (this.config.pollIntervalInMs) { + this.beginPolling(); + } + + return { + // on init, this supergraphSdl should never actually be `null`. + // `this.updateSupergraphSdl()` will only return null if the schema hasn't + // changed over the course of an _update_. + supergraphSdl: initialSupergraphSdl!, + cleanup: async () => { + if (this.state.phase === 'polling') { + await this.state.pollingPromise; + } + this.state = { phase: 'stopped' }; + if (this.timerRef) { + clearTimeout(this.timerRef); + this.timerRef = null; + } + }, + }; + } + + private async updateSupergraphSdl() { + const result = await this.config.updateServiceDefinitions( + this.config.gatewayConfig, + ); + + if (isSupergraphSdlUpdate(result)) { + // no change + if (this.compositionId === result.id) return null; + + await this.healthCheck?.(result.supergraphSdl); + this.compositionId = result.id; + return result.supergraphSdl; + } else if (isServiceDefinitionUpdate(result)) { + const supergraphSdl = this.updateByComposition(result); + if (!supergraphSdl) return null; + await this.healthCheck?.(supergraphSdl); + return supergraphSdl; + } else { + throw new Error( + 'Programming error: unexpected result type from `updateServiceDefinitions`', + ); + } + } + + private updateByComposition(result: ServiceDefinitionUpdate) { + if ( + !result.serviceDefinitions || + JSON.stringify(this.serviceDefinitions) === + JSON.stringify(result.serviceDefinitions) + ) { + this.config.logger?.debug( + 'No change in service definitions since last check.', + ); + return null; + } + + if (this.serviceDefinitions) { + this.config.logger?.info('New service definitions were found.'); + } + + this.serviceDefinitions = result.serviceDefinitions; + + const supergraphSdl = this.createSupergraphFromServiceList( + result.serviceDefinitions, + ); + + if (!supergraphSdl) { + throw new Error( + "A valid schema couldn't be composed. Falling back to previous schema.", + ); + } else { + return supergraphSdl; + } + } + + private createSupergraphFromServiceList(serviceList: ServiceDefinition[]) { + this.config.logger?.debug( + `Composing schema from service list: \n${serviceList + .map(({ name, url }) => ` ${url || 'local'}: ${name}`) + .join('\n')}`, + ); + + const compositionResult = composeAndValidate(serviceList); + + if (compositionHasErrors(compositionResult)) { + const { errors } = compositionResult; + throw Error( + "A valid schema couldn't be composed. The following composition errors were found:\n" + + errors.map((e) => '\t' + e.message).join('\n'), + ); + } else { + const { supergraphSdl } = compositionResult; + for (const service of serviceList) { + this.getDataSource?.(service); + } + + this.config.logger?.debug('Schema loaded and ready for execution'); + + return supergraphSdl; + } + } + + private beginPolling() { + this.state = { phase: 'polling' }; + this.poll(); + } + + private poll() { + this.timerRef = setTimeout(async () => { + if (this.state.phase === 'polling') { + const pollingPromise = resolvable(); + + this.state.pollingPromise = pollingPromise; + try { + const maybeNewSupergraphSdl = await this.updateSupergraphSdl(); + if (maybeNewSupergraphSdl) { + this.update?.(maybeNewSupergraphSdl); + } + } catch (e) { + this.logUpdateFailure(e); + } + pollingPromise.resolve(); + } + + this.poll(); + }, this.config.pollIntervalInMs!); + } + + private logUpdateFailure(e: any) { + this.config.logger?.error( + 'UplinkFetcher failed to update supergraph with the following error: ' + + (e.message ?? e), + ); + } +} diff --git a/gateway-js/src/supergraphManagers/LocalCompose/index.ts b/gateway-js/src/supergraphManagers/LocalCompose/index.ts new file mode 100644 index 0000000000..6150bc97a1 --- /dev/null +++ b/gateway-js/src/supergraphManagers/LocalCompose/index.ts @@ -0,0 +1,83 @@ +// TODO(trevor:removeServiceList) the whole file goes away +import { Logger } from 'apollo-server-types'; +import { + composeAndValidate, + compositionHasErrors, + ServiceDefinition, +} from '@apollo/federation'; +import { + GetDataSourceFunction, + SupergraphSdlHookOptions, + SupergraphManager, +} from '../../config'; + +export interface LocalComposeOptions { + logger?: Logger; + localServiceList: ServiceDefinition[]; +} + +export class LocalCompose implements SupergraphManager { + private config: LocalComposeOptions; + private getDataSource?: GetDataSourceFunction; + + constructor(options: LocalComposeOptions) { + this.config = options; + this.issueDeprecationWarnings(); + } + + private issueDeprecationWarnings() { + this.config.logger?.warn( + 'The `localServiceList` option is deprecated and will be removed in a future version of `@apollo/gateway`. Please migrate to the `LocalCompose` supergraph manager exported by `@apollo/gateway`.', + ); + } + + public async initialize({ getDataSource }: SupergraphSdlHookOptions) { + this.getDataSource = getDataSource; + let supergraphSdl: string | null = null; + try { + supergraphSdl = this.createSupergraphFromServiceList( + this.config.localServiceList, + ); + } catch (e) { + this.logUpdateFailure(e); + throw e; + } + return { + supergraphSdl, + }; + } + + private createSupergraphFromServiceList(serviceList: ServiceDefinition[]) { + this.config.logger?.debug( + `Composing schema from service list: \n${serviceList + .map(({ name, url }) => ` ${url || 'local'}: ${name}`) + .join('\n')}`, + ); + + const compositionResult = composeAndValidate(serviceList); + + if (compositionHasErrors(compositionResult)) { + const { errors } = compositionResult; + throw Error( + "A valid schema couldn't be composed. The following composition errors were found:\n" + + errors.map((e) => '\t' + e.message).join('\n'), + ); + } else { + const { supergraphSdl } = compositionResult; + for (const service of serviceList) { + this.getDataSource?.(service); + } + + this.config.logger?.debug('Schema loaded and ready for execution'); + + return supergraphSdl; + } + } + + private logUpdateFailure(e: any) { + this.config.logger?.error( + 'UplinkFetcher failed to update supergraph with the following error: ' + + (e.message ?? e), + ); + } +} diff --git a/gateway-js/src/__tests__/loadSupergraphSdlFromStorage.test.ts b/gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/loadSupergraphSdlFromStorage.test.ts similarity index 97% rename from gateway-js/src/__tests__/loadSupergraphSdlFromStorage.test.ts rename to gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/loadSupergraphSdlFromStorage.test.ts index efd3140e61..77d4e27a2d 100644 --- a/gateway-js/src/__tests__/loadSupergraphSdlFromStorage.test.ts +++ b/gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/loadSupergraphSdlFromStorage.test.ts @@ -2,7 +2,7 @@ import { loadSupergraphSdlFromStorage, loadSupergraphSdlFromUplinks } from '../loadSupergraphSdlFromStorage'; -import { getDefaultFetcher } from '../..'; +import { getDefaultFetcher } from '../../..'; import { graphRef, apiKey, @@ -14,9 +14,9 @@ import { mockSupergraphSdlRequestSuccess, mockSupergraphSdlRequestIfAfterUnchanged, mockSupergraphSdlRequestIfAfter -} from './integration/nockMocks'; -import { getTestingSupergraphSdl } from "./execution-utils"; -import { nockAfterEach, nockBeforeEach } from './nockAssertions'; +} from '../../../__tests__/integration/nockMocks'; +import { getTestingSupergraphSdl } from "../../../__tests__/execution-utils"; +import { nockAfterEach, nockBeforeEach } from '../../../__tests__/nockAssertions'; describe('loadSupergraphSdlFromStorage', () => { beforeEach(nockBeforeEach); diff --git a/gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/tsconfig.json b/gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/tsconfig.json new file mode 100644 index 0000000000..12ae429d6b --- /dev/null +++ b/gateway-js/src/supergraphManagers/UplinkFetcher/__tests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.test", + "include": ["**/*"], + "references": [ + { "path": "../../../../" }, + { "path": "../../../../../federation-integration-testsuite-js" }, + ] +} diff --git a/gateway-js/src/supergraphManagers/UplinkFetcher/index.ts b/gateway-js/src/supergraphManagers/UplinkFetcher/index.ts new file mode 100644 index 0000000000..cd079e58c5 --- /dev/null +++ b/gateway-js/src/supergraphManagers/UplinkFetcher/index.ts @@ -0,0 +1,128 @@ +import { fetch } from 'apollo-server-env'; +import { Logger } from 'apollo-server-types'; +import resolvable from '@josephg/resolvable'; +import { SupergraphManager, SupergraphSdlHookOptions } from '../../config'; +import { SubgraphHealthCheckFunction, SupergraphSdlUpdateFunction } from '../..'; +import { loadSupergraphSdlFromUplinks } from './loadSupergraphSdlFromStorage'; + +export interface UplinkFetcherOptions { + pollIntervalInMs: number; + subgraphHealthCheck?: boolean; + graphRef: string; + apiKey: string; + fetcher: typeof fetch; + maxRetries: number; + uplinkEndpoints: string[]; + logger?: Logger; +} + +type State = + | { phase: 'initialized' } + | { phase: 'polling'; pollingPromise?: Promise } + | { phase: 'stopped' }; + +export class UplinkFetcher implements SupergraphManager { + private config: UplinkFetcherOptions; + private update?: SupergraphSdlUpdateFunction; + private healthCheck?: SubgraphHealthCheckFunction; + private timerRef: NodeJS.Timeout | null = null; + private state: State; + private errorReportingEndpoint: string | undefined = + process.env.APOLLO_OUT_OF_BAND_REPORTER_ENDPOINT ?? undefined; + private compositionId?: string; + + constructor(options: UplinkFetcherOptions) { + this.config = options; + this.state = { phase: 'initialized' }; + } + + public async initialize({ update, healthCheck }: SupergraphSdlHookOptions) { + this.update = update; + + if (this.config.subgraphHealthCheck) { + this.healthCheck = healthCheck; + } + + let initialSupergraphSdl: string | null = null; + try { + initialSupergraphSdl = await this.updateSupergraphSdl(); + } catch (e) { + this.logUpdateFailure(e); + throw e; + } + + // Start polling after we resolve the first supergraph + this.beginPolling(); + + return { + // on init, this supergraphSdl should never actually be `null`. + // `this.updateSupergraphSdl()` will only return null if the schema hasn't + // changed over the course of an _update_. + supergraphSdl: initialSupergraphSdl!, + cleanup: async () => { + if (this.state.phase === 'polling') { + await this.state.pollingPromise; + } + this.state = { phase: 'stopped' }; + if (this.timerRef) { + clearTimeout(this.timerRef); + this.timerRef = null; + } + }, + }; + } + + private async updateSupergraphSdl() { + const result = await loadSupergraphSdlFromUplinks({ + graphRef: this.config.graphRef, + apiKey: this.config.apiKey, + endpoints: this.config.uplinkEndpoints, + errorReportingEndpoint: this.errorReportingEndpoint, + fetcher: this.config.fetcher, + compositionId: this.compositionId ?? null, + maxRetries: this.config.maxRetries, + }); + + if (!result) { + return null; + } else { + this.compositionId = result.id; + // the healthCheck fn is only assigned if it's enabled in the config + await this.healthCheck?.(result.supergraphSdl); + return result.supergraphSdl; + } + } + + private beginPolling() { + this.state = { phase: 'polling' }; + this.poll(); + } + + private poll() { + this.timerRef = setTimeout(async () => { + if (this.state.phase === 'polling') { + const pollingPromise = resolvable(); + + this.state.pollingPromise = pollingPromise; + try { + const maybeNewSupergraphSdl = await this.updateSupergraphSdl(); + if (maybeNewSupergraphSdl) { + this.update?.(maybeNewSupergraphSdl); + } + } catch (e) { + this.logUpdateFailure(e); + } + pollingPromise.resolve(); + } + + this.poll(); + }, this.config.pollIntervalInMs); + } + + private logUpdateFailure(e: any) { + this.config.logger?.error( + 'UplinkFetcher failed to update supergraph with the following error: ' + + (e.message ?? e), + ); + } +} diff --git a/gateway-js/src/loadSupergraphSdlFromStorage.ts b/gateway-js/src/supergraphManagers/UplinkFetcher/loadSupergraphSdlFromStorage.ts similarity index 96% rename from gateway-js/src/loadSupergraphSdlFromStorage.ts rename to gateway-js/src/supergraphManagers/UplinkFetcher/loadSupergraphSdlFromStorage.ts index 9bac8a2f6d..e12352ea7b 100644 --- a/gateway-js/src/loadSupergraphSdlFromStorage.ts +++ b/gateway-js/src/supergraphManagers/UplinkFetcher/loadSupergraphSdlFromStorage.ts @@ -1,8 +1,8 @@ import { fetch, Response, Request } from 'apollo-server-env'; import { GraphQLError } from 'graphql'; -import { SupergraphSdlUpdate } from './config'; +import { SupergraphSdlUpdate } from '../../config'; import { submitOutOfBandReportIfConfigured } from './outOfBandReporter'; -import { SupergraphSdlQuery } from './__generated__/graphqlTypes'; +import { SupergraphSdlQuery } from '../../__generated__/graphqlTypes'; // Magic /* GraphQL */ comment below is for codegen, do not remove export const SUPERGRAPH_SDL_QUERY = /* GraphQL */`#graphql @@ -35,7 +35,7 @@ interface SupergraphSdlQueryFailure { errors: GraphQLError[]; } -const { name, version } = require('../package.json'); +const { name, version } = require('../../../package.json'); const fetchErrorMsg = "An error occurred while fetching your schema from Apollo: "; diff --git a/gateway-js/src/outOfBandReporter.ts b/gateway-js/src/supergraphManagers/UplinkFetcher/outOfBandReporter.ts similarity index 96% rename from gateway-js/src/outOfBandReporter.ts rename to gateway-js/src/supergraphManagers/UplinkFetcher/outOfBandReporter.ts index a9dd28ffef..b751964642 100644 --- a/gateway-js/src/outOfBandReporter.ts +++ b/gateway-js/src/supergraphManagers/UplinkFetcher/outOfBandReporter.ts @@ -4,7 +4,7 @@ import { ErrorCode, OobReportMutation, OobReportMutationVariables, -} from './__generated__/graphqlTypes'; +} from '../../__generated__/graphqlTypes'; // Magic /* GraphQL */ comment below is for codegen, do not remove export const OUT_OF_BAND_REPORTER_QUERY = /* GraphQL */`#graphql @@ -13,7 +13,7 @@ export const OUT_OF_BAND_REPORTER_QUERY = /* GraphQL */`#graphql } `; -const { name, version } = require('../package.json'); +const { name, version } = require('../../../package.json'); type OobReportMutationResult = | OobReportMutationSuccess diff --git a/gateway-js/src/supergraphManagers/index.ts b/gateway-js/src/supergraphManagers/index.ts new file mode 100644 index 0000000000..da7ee9c16b --- /dev/null +++ b/gateway-js/src/supergraphManagers/index.ts @@ -0,0 +1,4 @@ +export { LocalCompose } from './LocalCompose'; +export { LegacyFetcher } from './LegacyFetcher'; +export { IntrospectAndCompose } from './IntrospectAndCompose'; +export { UplinkFetcher } from './UplinkFetcher'; diff --git a/gateway-js/src/utilities/createHash.ts b/gateway-js/src/utilities/createHash.ts new file mode 100644 index 0000000000..a7cf0ab828 --- /dev/null +++ b/gateway-js/src/utilities/createHash.ts @@ -0,0 +1,10 @@ +import isNodeLike from './isNodeLike'; + +export function createHash (kind: string): import('crypto').Hash { + if (isNodeLike) { + // Use module.require instead of just require to avoid bundling whatever + // crypto polyfills a non-Node bundler might fall back to. + return module.require('crypto').createHash(kind); + } + return require('sha.js')(kind); +} diff --git a/gateway-js/src/utilities/isNodeLike.ts b/gateway-js/src/utilities/isNodeLike.ts new file mode 100644 index 0000000000..e5fa3a2221 --- /dev/null +++ b/gateway-js/src/utilities/isNodeLike.ts @@ -0,0 +1,11 @@ +export default typeof process === 'object' && + process && + // We used to check `process.release.name === "node"`, however that doesn't + // account for certain forks of Node.js which are otherwise identical to + // Node.js. For example, NodeSource's N|Solid reports itself as "nsolid", + // though it's mostly the same build of Node.js with an extra addon. + process.release && + process.versions && + // The one thing which is present on both Node.js and N|Solid (a fork of + // Node.js), is `process.versions.node` being defined. + typeof process.versions.node === 'string'; diff --git a/package-lock.json b/package-lock.json index 064900231c..b16ad23c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@graphql-codegen/typescript": "2.4.2", "@graphql-codegen/typescript-operations": "2.2.2", "@iarna/toml": "2.2.5", + "@josephg/resolvable": "^1.0.1", "@opentelemetry/node": "0.24.0", "@rollup/plugin-commonjs": "21.0.1", "@rollup/plugin-json": "4.1.0", @@ -153,6 +154,7 @@ "@apollo/core-schema": "^0.2.0", "@apollo/federation": "file:../federation-js", "@apollo/query-planner": "file:../query-planner-js", + "@josephg/resolvable": "^1.0.1", "@opentelemetry/api": "^1.0.1", "@types/node-fetch": "2.5.12", "apollo-graphql": "^0.9.5", @@ -164,7 +166,8 @@ "apollo-server-types": "^0.9.0 || ^3.0.0 || ^3.5.0-alpha.0", "loglevel": "^1.6.1", "make-fetch-happen": "^8.0.0", - "pretty-format": "^27.3.1" + "pretty-format": "^27.3.1", + "sha.js": "^2.4.11" }, "engines": { "node": ">=12.13.0 <17.0" @@ -23819,6 +23822,7 @@ "@apollo/core-schema": "^0.2.0", "@apollo/federation": "file:../federation-js", "@apollo/query-planner": "file:../query-planner-js", + "@josephg/resolvable": "^1.0.1", "@opentelemetry/api": "^1.0.1", "@types/node-fetch": "2.5.12", "apollo-graphql": "^0.9.5", @@ -23830,7 +23834,8 @@ "apollo-server-types": "^0.9.0 || ^3.0.0 || ^3.5.0-alpha.0", "loglevel": "^1.6.1", "make-fetch-happen": "^8.0.0", - "pretty-format": "^27.3.1" + "pretty-format": "^27.3.1", + "sha.js": "^2.4.11" }, "dependencies": { "@jest/types": { diff --git a/package.json b/package.json index 244a8f277e..ae3fdde854 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@graphql-codegen/typescript": "2.4.2", "@graphql-codegen/typescript-operations": "2.2.2", "@iarna/toml": "2.2.5", + "@josephg/resolvable": "^1.0.1", "@opentelemetry/node": "0.24.0", "@rollup/plugin-commonjs": "21.0.1", "@rollup/plugin-json": "4.1.0",