diff --git a/gateway-js/src/__tests__/execution-utils.ts b/gateway-js/src/__tests__/execution-utils.ts index d922f887b9..95a4688677 100644 --- a/gateway-js/src/__tests__/execution-utils.ts +++ b/gateway-js/src/__tests__/execution-utils.ts @@ -117,8 +117,27 @@ 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 waitUntil() { + let userResolved: (value: T | PromiseLike) => void; + let userRejected: (reason?: any) => void; + const promise = new Promise( + (r) => ((userResolved = r), (userRejected = r)), + ); + return [ + promise, + // @ts-ignore + userResolved, + // @ts-ignore + userRejected, + ] as [ + Promise, + (value: T | PromiseLike) => void, + (reason?: any) => void, + ]; } export function printPlan(queryPlan: QueryPlan): string { 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..de5dd2c2ae 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -209,7 +209,6 @@ describe('lifecycle hooks', () => { experimental_pollInterval: 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.', ); 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..7c4b853c6f --- /dev/null +++ b/gateway-js/src/__tests__/gateway/supergraphSdl.test.ts @@ -0,0 +1,204 @@ +import { ApolloGateway } from '@apollo/gateway'; +import { fixturesWithUpdate } from 'apollo-federation-integration-testsuite'; +import { createHash } from 'crypto'; +import { ApolloServer } from 'apollo-server'; +import { Logger } from 'apollo-server-types'; +import { fetch } from '../../__mocks__/apollo-server-env'; +import { getTestingSupergraphSdl, waitUntil } from '../execution-utils'; + +async function getSupergraphSdlGatewayServer() { + const server = new ApolloServer({ + gateway: new ApolloGateway({ + supergraphSdl: getTestingSupergraphSdl(), + }), + }); + + await server.listen({ port: 0 }); + return server; +} + +let logger: Logger; +beforeEach(() => { + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +}); + +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('starts and remains in `initialized` state until user Promise resolves', async () => { + const [forGatewayToHaveCalledSupergraphSdl, resolve] = waitUntil(); + + const gateway = new ApolloGateway({ + async supergraphSdl() { + resolve(); + return new Promise(() => {}); + }, + }); + + await forGatewayToHaveCalledSupergraphSdl; + expect(gateway.__testing().state.phase).toEqual('initialized'); + }); + + it('starts and waits in `initialized` state after calling load but before user Promise resolves', async () => { + const gateway = new ApolloGateway({ + async supergraphSdl() { + return new Promise(() => {}); + }, + }); + + gateway.load(); + + expect(gateway.__testing().state.phase).toEqual('initialized'); + }); + + it('moves from `initialized` to `loaded` state after calling `load()` and after user Promise resolves', async () => { + const [userPromise, resolveSupergraph] = + waitUntil<{ supergraphSdl: string }>(); + + const 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'); + resolveSupergraph({ 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, resolveSupergraph] = + waitUntil<{ supergraphSdl: string }>(); + + let userUpdateFn: (updatedSupergraphSdl: string) => Promise; + const gateway = new ApolloGateway({ + async supergraphSdl(update) { + userUpdateFn = update; + return userPromise; + }, + }); + + const supergraphSdl = getTestingSupergraphSdl(); + const expectedId = createHash('sha256').update(supergraphSdl).digest('hex'); + resolveSupergraph({ supergraphSdl: getTestingSupergraphSdl() }); + await gateway.load(); + expect(gateway.__testing().compositionId).toEqual(expectedId); + + const updatedSupergraphSdl = getTestingSupergraphSdl(fixturesWithUpdate); + const expectedUpdatedId = createHash('sha256') + .update(updatedSupergraphSdl) + .digest('hex'); + await userUpdateFn!(updatedSupergraphSdl); + expect(gateway.__testing().compositionId).toEqual(expectedUpdatedId); + }); + + it('calls user-provided `cleanup` function when stopped', async () => { + const cleanup = jest.fn(() => Promise.resolve()); + const 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); + }); + + describe('errors', () => { + it('fails to load if user-provided `supergraphSdl` function throws', async () => { + const gateway = new ApolloGateway({ + async supergraphSdl() { + throw new Error('supergraphSdl failed'); + }, + logger, + }); + + await expect(() => + gateway.load(), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"User provided \`supergraphSdl\` function did not return an object containing a \`supergraphSdl\` property"`, + ); + + expect(gateway.__testing().state.phase).toEqual('failed to load'); + expect(logger.error).toHaveBeenCalledWith( + 'User-defined `supergraphSdl` function threw error: supergraphSdl failed', + ); + }); + + it('gracefully handles Promise rejections from user `cleanup` function', async () => { + const rejectionMessage = 'thrown from cleanup function'; + const cleanup = jest.fn(() => + Promise.reject(rejectionMessage), + ); + const 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, + ); + }); + }); +}); diff --git a/gateway-js/src/config.ts b/gateway-js/src/config.ts index bd736f860e..2d1f4b00fe 100644 --- a/gateway-js/src/config.ts +++ b/gateway-js/src/config.ts @@ -166,10 +166,17 @@ interface ExperimentalManuallyManagedSupergraphSdlGatewayConfig experimental_updateSupergraphSdl: Experimental_UpdateSupergraphSdl; } +export function isManuallyManagedSupergraphSdlGatewayConfig( + config: GatewayConfig, +): config is ManuallyManagedSupergraphSdlGatewayConfig { + return ( + 'supergraphSdl' in config && typeof config.supergraphSdl === 'function' + ); +} interface ManuallyManagedSupergraphSdlGatewayConfig extends GatewayConfigBase { supergraphSdl: ( - update: (updatedSupergraphSdl: string) => void, - ) => Promise void }>; + update: (updatedSupergraphSdl: string) => Promise, + ) => Promise<{ supergraphSdl: string; cleanup?: () => Promise }>; } type ManuallyManagedGatewayConfig = diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index 97e5744c68..d07c5d3188 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -69,6 +69,7 @@ import { ServiceDefinitionUpdate, SupergraphSdlUpdate, CompositionUpdate, + isManuallyManagedSupergraphSdlGatewayConfig, } from './config'; import { loadSupergraphSdlFromStorage } from './loadSupergraphSdlFromStorage'; import { buildComposedSchema } from '@apollo/query-planner'; @@ -76,6 +77,7 @@ import { SpanStatusCode } from '@opentelemetry/api'; import { OpenTelemetrySpanNames, tracer } from './utilities/opentelemetry'; import { CoreSchema } from '@apollo/core-schema'; import { featureSupport } from './core'; +import { createHash } from 'crypto'; type DataSourceMap = { [serviceName: string]: { url?: string; dataSource: GraphQLDataSource }; @@ -198,13 +200,20 @@ export class ApolloGateway implements GraphQLService { 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; + private updateServiceDefinitions?: Experimental_UpdateComposition; // how often service defs should be loaded/updated (in ms) private experimental_pollInterval?: number; // Configure the endpoint by which gateway will access its precomposed schema. // * `string` means use that endpoint // * `undefined` means the gateway is not using managed federation private schemaConfigDeliveryEndpoint?: string; + // The Promise case is strictly for handling the case where the user-provided + // function throws an error. + private manualConfigPromise?: Promise<{ + supergraphSdl: string; + cleanup?: () => Promise; + } | void>; + private toDispose: (() => Promise)[] = []; constructor(config?: GatewayConfig) { this.config = { @@ -250,6 +259,16 @@ export class ApolloGateway implements GraphQLService { } else if ('experimental_updateServiceDefinitions' in this.config) { this.updateServiceDefinitions = this.config.experimental_updateServiceDefinitions; + } else if (isManuallyManagedSupergraphSdlGatewayConfig(this.config)) { + this.manualConfigPromise = this.config + .supergraphSdl(this.updateWithSupergraphSdl.bind(this)) + .catch((e) => { + // Not swallowing the error here results in an uncaught rejection. + // An error will be thrown when this promise resolves to nothing. + this.logger.error( + 'User-defined `supergraphSdl` function threw error: ' + e.message, + ); + }); } else { throw Error( 'Programming error: unexpected manual configuration provided', @@ -429,9 +448,13 @@ export class ApolloGateway implements GraphQLService { this.maybeWarnOnConflictingConfig(); // Handles initial assignment of `this.schema`, `this.queryPlanner` - isStaticConfig(this.config) - ? this.loadStatic(this.config) - : await this.loadDynamic(unrefTimer); + if (isStaticConfig(this.config)) { + this.loadStatic(this.config); + } else if (isManuallyManagedSupergraphSdlGatewayConfig(this.config)) { + await this.loadManuallyManaged(); + } else { + await this.loadDynamic(unrefTimer); + } const mode = isManagedConfig(this.config) ? 'managed' : 'unmanaged'; this.logger.info( @@ -484,6 +507,25 @@ export class ApolloGateway implements GraphQLService { } } + private async loadManuallyManaged() { + try { + const result = await this.manualConfigPromise; + if (!result?.supergraphSdl) + throw new Error( + 'User provided `supergraphSdl` function did not return an object containing a `supergraphSdl` property', + ); + if (result?.cleanup) { + this.toDispose.push(result.cleanup); + } + await this.updateWithSupergraphSdl(result.supergraphSdl); + } catch (e) { + this.state = { phase: 'failed to load' }; + throw e; + } + + this.state = { phase: 'loaded' }; + } + private shouldBeginPolling() { return isManagedConfig(this.config) || this.experimental_pollInterval; } @@ -491,6 +533,11 @@ export class ApolloGateway implements GraphQLService { private async updateSchema(): Promise { this.logger.debug('Checking for composition updates...'); + if (!this.updateServiceDefinitions) { + throw new Error( + 'Programming error: `updateSchema` was called unexpectedly.', + ); + } // This may throw, but an error here is caught and logged upstream const result = await this.updateServiceDefinitions(this.config); @@ -564,19 +611,28 @@ export class ApolloGateway implements GraphQLService { } private async updateWithSupergraphSdl( - result: SupergraphSdlUpdate, + result: SupergraphSdlUpdate | string, ): Promise { - if (result.id === this.compositionId) { + if (typeof result === 'string') { + } else if (result.id === this.compositionId) { this.logger.debug('No change in composition since last check.'); return; } + const supergraphSdl = + typeof result === 'string' ? result : result.supergraphSdl; + + const id = + typeof result === 'string' + ? createHash('sha256').update(result).digest('hex') + : result.id; + // 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; @@ -586,28 +642,27 @@ export class ApolloGateway implements GraphQLService { this.logger.info('Updated Supergraph SDL was found.'); } - await this.maybePerformServiceHealthCheck(result); + await this.maybePerformServiceHealthCheck({ supergraphSdl, id }); - 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( { - compositionId: result.id, - supergraphSdl: result.supergraphSdl, + compositionId: id, + supergraphSdl: supergraphSdl, schema, }, previousCompositionId && previousSupergraphSdl && previousSchema @@ -1230,6 +1285,17 @@ export class ApolloGateway implements GraphQLService { // schema polling). Can be called multiple times safely. Once it (async) // returns, all gateway background activity will be finished. public async stop() { + 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 = []; switch (this.state.phase) { case 'initialized': case 'failed to load': @@ -1285,6 +1351,14 @@ export class ApolloGateway implements GraphQLService { throw new UnreachableCaseError(this.state); } } + + public __testing() { + return { + state: this.state, + compositionId: this.compositionId, + supergraphSdl: this.supergraphSdl, + } + } } ApolloGateway.prototype.onSchemaChange = deprecate(