diff --git a/gateway-js/CHANGELOG.md b/gateway-js/CHANGELOG.md index b1d8f7ebc..879ffba2c 100644 --- a/gateway-js/CHANGELOG.md +++ b/gateway-js/CHANGELOG.md @@ -4,6 +4,8 @@ > 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. +- Simplify startup code paths. This is technically only intended to be an internal restructure, but it's substantial enough to warrant a changelog entry for observability in case of any unexpected behavioral changes. [PR #440](https://github.com/apollographql/federation/pull/440) + ## v0.22.0 - Include original error during creation of `GraphQLError` in `downstreamServiceError()`. [PR #309](https://github.com/apollographql/federation/pull/309) diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index 0daba3aa5..881915525 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -1,10 +1,9 @@ import gql from 'graphql-tag'; +import { ApolloGateway } from '../..'; import { - ApolloGateway, - GatewayConfig, Experimental_DidResolveQueryPlanCallback, Experimental_UpdateServiceDefinitions, -} from '../../index'; +} from '../../config'; import { product, reviews, @@ -49,7 +48,7 @@ beforeEach(() => { describe('lifecycle hooks', () => { it('uses updateServiceDefinitions override', async () => { const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = jest.fn( - async (_config: GatewayConfig) => { + async () => { return { serviceDefinitions, isNewSchema: true }; }, ); @@ -71,7 +70,7 @@ describe('lifecycle hooks', () => { const experimental_didFailComposition = jest.fn(); const gateway = new ApolloGateway({ - async experimental_updateServiceDefinitions(_config: GatewayConfig) { + async experimental_updateServiceDefinitions() { return { serviceDefinitions: [serviceDefinitions[0]], compositionMetadata: { @@ -107,9 +106,7 @@ describe('lifecycle hooks', () => { schemaHash: 'hash1', }; - const update: Experimental_UpdateServiceDefinitions = async ( - _config: GatewayConfig, - ) => ({ + const update: Experimental_UpdateServiceDefinitions = async () => ({ serviceDefinitions, isNewSchema: true, compositionMetadata: { @@ -124,7 +121,7 @@ describe('lifecycle hooks', () => { // We want to return a different composition across two ticks, so we mock it // slightly differenty - mockUpdate.mockImplementationOnce(async (_config: GatewayConfig) => { + mockUpdate.mockImplementationOnce(async () => { const services = serviceDefinitions.filter(s => s.name !== 'books'); return { serviceDefinitions: [ @@ -215,7 +212,7 @@ describe('lifecycle hooks', () => { it('registers schema change callbacks when experimental_pollInterval is set for unmanaged configs', async () => { const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = jest.fn( - async (_config: GatewayConfig) => { + async (_config) => { return { serviceDefinitions, isNewSchema: true }; }, ); diff --git a/gateway-js/src/__tests__/integration/configuration.test.ts b/gateway-js/src/__tests__/integration/configuration.test.ts new file mode 100644 index 000000000..974ca96e2 --- /dev/null +++ b/gateway-js/src/__tests__/integration/configuration.test.ts @@ -0,0 +1,169 @@ +import { Logger } from 'apollo-server-types'; +import { ApolloGateway } from '../..'; +import { + mockSDLQuerySuccess, + mockStorageSecretSuccess, + mockCompositionConfigLinkSuccess, + mockCompositionConfigsSuccess, + mockImplementingServicesSuccess, + mockRawPartialSchemaSuccess, + apiKeyHash, + graphId, +} from './nockMocks'; +import { getTestingCsdl } from '../execution-utils'; +import { MockService } from './networkRequests.test'; +import { parse } from 'graphql'; + +let logger: Logger; + +const service: MockService = { + gcsDefinitionPath: 'service-definition.json', + partialSchemaPath: 'accounts-partial-schema.json', + url: 'http://localhost:4001', + sdl: `#graphql + extend type Query { + me: User + everyone: [User] + } + + "This is my User" + type User @key(fields: "id") { + id: ID! + name: String + username: String + } + `, +}; + +beforeEach(() => { + const warn = jest.fn(); + const debug = jest.fn(); + const error = jest.fn(); + const info = jest.fn(); + + logger = { + warn, + debug, + error, + info, + }; +}); + +describe('gateway configuration warnings', () => { + it('warns when both csdl and studio configuration are provided', async () => { + const gateway = new ApolloGateway({ + csdl: getTestingCsdl(), + logger, + }); + + await gateway.load({ + apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, + }); + + expect(logger.warn).toHaveBeenCalledWith( + 'A local gateway configuration is overriding a managed federation configuration.' + + ' To use the managed configuration, do not specify a service list or csdl locally.', + ); + }); + + it('conflicting configurations are warned about when present', async () => { + mockSDLQuerySuccess(service); + + const gateway = new ApolloGateway({ + serviceList: [{ name: 'accounts', url: service.url }], + logger, + }); + + await gateway.load({ + apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching( + /A local gateway configuration is overriding a managed federation configuration/, + ), + ); + }); + + it('conflicting configurations are not warned about when absent', async () => { + mockStorageSecretSuccess(); + mockCompositionConfigLinkSuccess(); + mockCompositionConfigsSuccess([service]); + mockImplementingServicesSuccess(service); + mockRawPartialSchemaSuccess(service); + + const gateway = new ApolloGateway({ + logger, + }); + + await gateway.load({ + apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, + }); + + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringMatching( + /A local gateway configuration is overriding a managed federation configuration/, + ), + ); + }); + + it('throws when no configuration is provided', async () => { + const gateway = new ApolloGateway({ + logger, + }); + + expect(gateway.load()).rejects.toThrowErrorMatchingInlineSnapshot( + `"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\`, \`csdl\`, and \`experimental_updateServiceDefinitions\`."`, + ); + }); +}); + +describe('gateway startup errors', () => { + it("throws when static config can't be composed", async () => { + const uncomposableSdl = parse(`#graphql + type Query { + me: User + everyone: [User] + account(id: String): Account + } + + type User @key(fields: "id") { + name: String + username: String + } + + type Account @key(fields: "id") { + name: String + username: String + } + `); + + const gateway = new ApolloGateway({ + localServiceList: [ + { name: 'accounts', url: service.url, typeDefs: uncomposableSdl }, + ], + logger, + }); + + // 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(gateway.load()).rejects.toThrowErrorMatchingInlineSnapshot(` + // "A valid schema couldn't be composed. The following composition errors were found: + // [accounts] User -> A @key selects id, but User.id could not be found + // [accounts] Account -> A @key selects id, but Account.id could not be found" + // `); + // Instead we'll just use the regular snapshot matcher... + let err: any; + try { + await gateway.load(); + } catch (e) { + err = e; + } + + expect(err.message).toMatchInlineSnapshot(` + "A valid schema couldn't be composed. The following composition errors were found: + [accounts] User -> A @key selects id, but User.id could not be found + [accounts] Account -> A @key selects id, but Account.id could not be found" + `); + }); +}); diff --git a/gateway-js/src/__tests__/integration/networkRequests.test.ts b/gateway-js/src/__tests__/integration/networkRequests.test.ts index af423fc07..a0b04500a 100644 --- a/gateway-js/src/__tests__/integration/networkRequests.test.ts +++ b/gateway-js/src/__tests__/integration/networkRequests.test.ts @@ -19,12 +19,6 @@ import { apiKeyHash, graphId, } from './nockMocks'; -import loadServicesFromStorage = require('../../loadServicesFromStorage'); -import { getTestingCsdl } from '../execution-utils'; - -// This is a nice DX hack for GraphQL code highlighting and formatting within the file. -// Anything wrapped within the gql tag within this file is just a string, not an AST. -const gql = String.raw; export interface MockService { gcsDefinitionPath: string; @@ -37,7 +31,7 @@ const service: MockService = { gcsDefinitionPath: 'service-definition.json', partialSchemaPath: 'accounts-partial-schema.json', url: 'http://localhost:4001', - sdl: gql` + sdl: `#graphql extend type Query { me: User everyone: [User] @@ -56,7 +50,7 @@ const updatedService: MockService = { gcsDefinitionPath: 'updated-service-definition.json', partialSchemaPath: 'updated-accounts-partial-schema.json', url: 'http://localhost:4002', - sdl: gql` + sdl: `#graphql extend type Query { me: User everyone: [User] @@ -130,100 +124,6 @@ it('Extracts service definitions from remote storage', async () => { expect(gateway.schema!.getType('User')!.description).toBe('This is my User'); }); -it.each([ - ['warned', 'present'], - ['not warned', 'absent'], -])('conflicting configurations are %s about when %s', async (_word, mode) => { - const isConflict = mode === 'present'; - let blockerResolve: () => void; - const blocker = new Promise((resolve) => (blockerResolve = resolve)); - const original = loadServicesFromStorage.getServiceDefinitionsFromStorage; - const spyGetServiceDefinitionsFromStorage = jest - .spyOn(loadServicesFromStorage, 'getServiceDefinitionsFromStorage') - .mockImplementationOnce(async (...args) => { - try { - return await original(...args); - } catch (e) { - throw e; - } finally { - setImmediate(blockerResolve); - } - }); - - mockStorageSecretSuccess(); - if (isConflict) { - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([service]); - mockImplementingServicesSuccess(service); - mockRawPartialSchemaSuccess(service); - } else { - mockCompositionConfigLink().reply(403); - } - - mockSDLQuerySuccess(service); - - const gateway = new ApolloGateway({ - serviceList: [{ name: 'accounts', url: service.url }], - logger, - }); - - await gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }); - await blocker; // Wait for the definitions to be "fetched". - - (isConflict - ? expect(logger.warn) - : expect(logger.warn).not - ).toHaveBeenCalledWith( - expect.stringMatching( - /A local gateway configuration is overriding a managed federation configuration/, - ), - ); - spyGetServiceDefinitionsFromStorage.mockRestore(); -}); - -it('warns when both csdl and studio configuration are provided', async () => { - mockStorageSecretSuccess(); - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([service]); - mockImplementingServicesSuccess(service); - mockRawPartialSchemaSuccess(service); - - let blockerResolve: () => void; - const blocker = new Promise((resolve) => (blockerResolve = resolve)); - const original = loadServicesFromStorage.getServiceDefinitionsFromStorage; - const spyGetServiceDefinitionsFromStorage = jest - .spyOn(loadServicesFromStorage, 'getServiceDefinitionsFromStorage') - .mockImplementationOnce(async (...args) => { - try { - return await original(...args); - } catch (e) { - throw e; - } finally { - setImmediate(blockerResolve); - } - }); - - const gateway = new ApolloGateway({ - csdl: getTestingCsdl(), - logger, - }); - - await gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }); - - await blocker; - - expect(logger.warn).toHaveBeenCalledWith( - 'A local gateway configuration is overriding a managed federation configuration.' + - ' To use the managed configuration, do not specify a service list or csdl locally.', - ); - - spyGetServiceDefinitionsFromStorage.mockRestore(); -}); - // This test has been flaky for a long time, and fails consistently after changes // introduced by https://github.com/apollographql/apollo-server/pull/4277. // I've decided to skip this test for now with hopes that we can one day diff --git a/gateway-js/src/config.ts b/gateway-js/src/config.ts new file mode 100644 index 000000000..44fb9a49b --- /dev/null +++ b/gateway-js/src/config.ts @@ -0,0 +1,159 @@ +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 { GraphQLDataSource } from './datasources/types'; +import { QueryPlan, OperationContext } from './QueryPlan'; +import { ServiceMap } from './executeQueryPlan'; +import { + CompositionMetadata, +} from './loadServicesFromStorage'; + +export type ServiceEndpointDefinition = Pick; + +export type Experimental_DidResolveQueryPlanCallback = ({ + queryPlan, + serviceMap, + operationContext, + requestContext, +}: { + readonly queryPlan: QueryPlan; + readonly serviceMap: ServiceMap; + readonly operationContext: OperationContext; + readonly requestContext: GraphQLRequestContextExecutionDidStart< + Record + >; +}) => void; + +export type Experimental_DidFailCompositionCallback = ({ + errors, + serviceList, + compositionMetadata, +}: { + readonly errors: GraphQLError[]; + readonly serviceList: ServiceDefinition[]; + readonly compositionMetadata?: CompositionMetadata; +}) => void; + +export interface Experimental_CompositionInfo { + serviceDefinitions: ServiceDefinition[]; + schema: GraphQLSchema; + compositionMetadata?: CompositionMetadata; +} + +export type Experimental_DidUpdateCompositionCallback = ( + currentConfig: Experimental_CompositionInfo, + previousConfig?: Experimental_CompositionInfo, +) => void; + +/** + * **Note:** It's possible for a schema to be the same (`isNewSchema: false`) when + * `serviceDefinitions` have changed. For example, during type migration, the + * composed schema may be identical but the `serviceDefinitions` would differ + * since a type has moved from one service to another. + */ +export type Experimental_UpdateServiceDefinitions = ( + config: DynamicGatewayConfig, +) => Promise<{ + serviceDefinitions?: ServiceDefinition[]; + compositionMetadata?: CompositionMetadata; + isNewSchema: boolean; +}>; + +interface GatewayConfigBase { + debug?: boolean; + logger?: Logger; + // TODO: expose the query plan in a more flexible JSON format in the future + // and remove this config option in favor of `exposeQueryPlan`. Playground + // should cutover to use the new option when it's built. + __exposeQueryPlanExperimental?: boolean; + buildService?: (definition: ServiceEndpointDefinition) => GraphQLDataSource; + + // experimental observability callbacks + experimental_didResolveQueryPlan?: Experimental_DidResolveQueryPlanCallback; + experimental_didFailComposition?: Experimental_DidFailCompositionCallback; + experimental_didUpdateComposition?: Experimental_DidUpdateCompositionCallback; + experimental_pollInterval?: number; + experimental_approximateQueryPlanStoreMiB?: number; + experimental_autoFragmentization?: boolean; + fetcher?: typeof fetch; + serviceHealthCheck?: boolean; +} + +export interface RemoteGatewayConfig extends GatewayConfigBase { + serviceList: ServiceEndpointDefinition[]; + introspectionHeaders?: HeadersInit; +} + +export interface ManagedGatewayConfig extends GatewayConfigBase { + federationVersion?: number; +} + +interface ManuallyManagedGatewayConfig extends GatewayConfigBase { + experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions; +} +interface LocalGatewayConfig extends GatewayConfigBase { + localServiceList: ServiceDefinition[]; +} + +interface CsdlGatewayConfig extends GatewayConfigBase { + csdl: string; +} + +export type StaticGatewayConfig = LocalGatewayConfig | CsdlGatewayConfig; + +type DynamicGatewayConfig = +| ManagedGatewayConfig +| RemoteGatewayConfig +| ManuallyManagedGatewayConfig; + +export type GatewayConfig = StaticGatewayConfig | DynamicGatewayConfig; + +export function isLocalConfig(config: GatewayConfig): config is LocalGatewayConfig { + return 'localServiceList' in config; +} + +export function isRemoteConfig(config: GatewayConfig): config is RemoteGatewayConfig { + return 'serviceList' in config; +} + +export function isCsdlConfig(config: GatewayConfig): config is CsdlGatewayConfig { + return 'csdl' in config; +} + +// A manually managed config means the user has provided a function which +// handles providing service definitions to the gateway. +export function isManuallyManagedConfig( + config: GatewayConfig, +): config is ManuallyManagedGatewayConfig { + return 'experimental_updateServiceDefinitions' in config; +} + +// Managed config strictly means managed by Studio +export function isManagedConfig( + config: GatewayConfig, +): config is ManagedGatewayConfig { + return ( + !isRemoteConfig(config) && + !isLocalConfig(config) && + !isCsdlConfig(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) || isCsdlConfig(config); +} + +// A dynamic config is one which loads asynchronously and (can) update via polling +export function isDynamicConfig( + config: GatewayConfig, +): config is DynamicGatewayConfig { + return ( + isRemoteConfig(config) || + isManagedConfig(config) || + isManuallyManagedConfig(config) + ); +} diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index 91fa3f68c..95ea03388 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -15,13 +15,11 @@ import { isObjectType, isIntrospectionType, GraphQLSchema, - GraphQLError, VariableDefinitionNode, parse, visit, DocumentNode, } from 'graphql'; -import { GraphQLSchemaValidationError } from 'apollo-graphql'; import { composeAndValidate, compositionHasErrors, @@ -47,130 +45,35 @@ import { import { serializeQueryPlan, QueryPlan, OperationContext, WasmPointer } from './QueryPlan'; import { GraphQLDataSource } from './datasources/types'; import { RemoteGraphQLDataSource } from './datasources/RemoteGraphQLDataSource'; -import { HeadersInit } from 'node-fetch'; import { getVariableValues } from 'graphql/execution/values'; import fetcher from 'make-fetch-happen'; import { HttpRequestCache } from './cache'; import { fetch } from 'apollo-server-env'; import { getQueryPlanner } from '@apollo/query-planner-wasm'; import { csdlToSchema } from './csdlToSchema'; - -export type ServiceEndpointDefinition = Pick; - -interface GatewayConfigBase { - debug?: boolean; - logger?: Logger; - // TODO: expose the query plan in a more flexible JSON format in the future - // and remove this config option in favor of `exposeQueryPlan`. Playground - // should cutover to use the new option when it's built. - __exposeQueryPlanExperimental?: boolean; - buildService?: (definition: ServiceEndpointDefinition) => GraphQLDataSource; - - // experimental observability callbacks - experimental_didResolveQueryPlan?: Experimental_DidResolveQueryPlanCallback; - experimental_didFailComposition?: Experimental_DidFailCompositionCallback; - experimental_updateServiceDefinitions?: Experimental_UpdateServiceDefinitions; - experimental_didUpdateComposition?: Experimental_DidUpdateCompositionCallback; - experimental_pollInterval?: number; - experimental_approximateQueryPlanStoreMiB?: number; - experimental_autoFragmentization?: boolean; - fetcher?: typeof fetch; - serviceHealthCheck?: boolean; -} - -interface RemoteGatewayConfig extends GatewayConfigBase { - serviceList: ServiceEndpointDefinition[]; - introspectionHeaders?: HeadersInit; -} - -interface ManagedGatewayConfig extends GatewayConfigBase { - federationVersion?: number; -} -interface LocalGatewayConfig extends GatewayConfigBase { - localServiceList: ServiceDefinition[]; -} - -interface CsdlGatewayConfig extends GatewayConfigBase { - csdl: string; -} - -export type GatewayConfig = - | RemoteGatewayConfig - | LocalGatewayConfig - | ManagedGatewayConfig - | CsdlGatewayConfig; +import { + ServiceEndpointDefinition, + Experimental_DidFailCompositionCallback, + Experimental_DidResolveQueryPlanCallback, + Experimental_DidUpdateCompositionCallback, + Experimental_UpdateServiceDefinitions, + Experimental_CompositionInfo, + GatewayConfig, + StaticGatewayConfig, + RemoteGatewayConfig, + ManagedGatewayConfig, + isManuallyManagedConfig, + isLocalConfig, + isRemoteConfig, + isManagedConfig, + isDynamicConfig, + isStaticConfig, +} from './config'; type DataSourceMap = { [serviceName: string]: { url?: string; dataSource: GraphQLDataSource }; }; -function isLocalConfig(config: GatewayConfig): config is LocalGatewayConfig { - return 'localServiceList' in config; -} - -function isRemoteConfig(config: GatewayConfig): config is RemoteGatewayConfig { - return 'serviceList' in config; -} - -function isCsdlConfig(config: GatewayConfig): config is CsdlGatewayConfig { - return 'csdl' in config; -} - -function isManagedConfig( - config: GatewayConfig, -): config is ManagedGatewayConfig { - return ( - !isRemoteConfig(config) && !isLocalConfig(config) && !isCsdlConfig(config) - ); -} - -export type Experimental_DidResolveQueryPlanCallback = ({ - queryPlan, - serviceMap, - operationContext, - requestContext, -}: { - readonly queryPlan: QueryPlan; - readonly serviceMap: ServiceMap; - readonly operationContext: OperationContext; - readonly requestContext: GraphQLRequestContextExecutionDidStart>; -}) => void; - -export type Experimental_DidFailCompositionCallback = ({ - errors, - serviceList, - compositionMetadata, -}: { - readonly errors: GraphQLError[]; - readonly serviceList: ServiceDefinition[]; - readonly compositionMetadata?: CompositionMetadata; -}) => void; - -export interface Experimental_CompositionInfo { - serviceDefinitions: ServiceDefinition[]; - schema: GraphQLSchema; - compositionMetadata?: CompositionMetadata; -} - -export type Experimental_DidUpdateCompositionCallback = ( - currentConfig: Experimental_CompositionInfo, - previousConfig?: Experimental_CompositionInfo, -) => void; - -/** - * **Note:** It's possible for a schema to be the same (`isNewSchema: false`) when - * `serviceDefinitions` have changed. For example, during type migration, the - * composed schema may be identical but the `serviceDefinitions` would differ - * since a type has moved from one service to another. - */ -export type Experimental_UpdateServiceDefinitions = ( - config: GatewayConfig, -) => Promise<{ - serviceDefinitions?: ServiceDefinition[]; - compositionMetadata?: CompositionMetadata; - isNewSchema: boolean; -}>; - type Await = T extends Promise ? U : T; // Local state to track whether particular UX-improving warning messages have @@ -214,7 +117,7 @@ export class ApolloGateway implements GraphQLService { protected serviceMap: DataSourceMap = Object.create(null); protected config: GatewayConfig; private logger: Logger; - protected queryPlanStore?: InMemoryLRUCache; + protected queryPlanStore: InMemoryLRUCache; private apolloConfig?: ApolloConfig; private pollingTimer?: NodeJS.Timer; private onSchemaChangeListeners = new Set(); @@ -224,8 +127,7 @@ export class ApolloGateway implements GraphQLService { private warnedStates: WarnedStates = Object.create(null); private queryPlannerPointer?: WasmPointer; private parsedCsdl?: DocumentNode; - - private fetcher: typeof fetch = getDefaultGcsFetcher(); + private fetcher: typeof fetch; // Observe query plan, service info, and operation info prior to execution. // The information made available here will give insight into the resulting @@ -244,8 +146,6 @@ export class ApolloGateway implements GraphQLService { // how often service defs should be loaded/updated (in ms) protected experimental_pollInterval?: number; - private experimental_approximateQueryPlanStoreMiB?: number; - constructor(config?: GatewayConfig) { this.config = { // TODO: expose the query plan in a more flexible JSON format in the future @@ -255,91 +155,94 @@ export class ApolloGateway implements GraphQLService { ...config, }; + this.logger = this.initLogger(); + this.queryPlanStore = this.initQueryPlanStore( + config?.experimental_approximateQueryPlanStoreMiB, + ); + this.fetcher = config?.fetcher || getDefaultGcsFetcher(); + + // 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; + + // Use the provided updater function if provided by the user, else default + this.updateServiceDefinitions = isManuallyManagedConfig(this.config) + ? this.config.experimental_updateServiceDefinitions + : this.loadServiceDefinitions; + + if (isDynamicConfig(this.config)) { + this.issueDynamicWarningsIfApplicable(); + } + } + + private initLogger() { // Setup logging facilities if (this.config.logger) { - this.logger = this.config.logger; - } else { - // If the user didn't provide their own logger, we'll initialize one. - const loglevelLogger = loglevel.getLogger(`apollo-gateway`); - - // And also support the `debug` option, if it's truthy. - if (this.config.debug === true) { - loglevelLogger.setLevel(loglevelLogger.levels.DEBUG); - } else { - loglevelLogger.setLevel(loglevelLogger.levels.WARN); - } - - this.logger = loglevelLogger; + return this.config.logger; } - if (isLocalConfig(this.config)) { - const { schema, composedSdl } = this.createSchema({ - serviceList: this.config.localServiceList, - }); - this.schema = schema; + // If the user didn't provide their own logger, we'll initialize one. + const loglevelLogger = loglevel.getLogger(`apollo-gateway`); - if (!composedSdl) { - this.logger.error("A valid schema couldn't be composed.") - } else { - this.queryPlannerPointer = getQueryPlanner(composedSdl); - } + // And also support the `debug` option, if it's truthy. + if (this.config.debug === true) { + loglevelLogger.setLevel(loglevelLogger.levels.DEBUG); + } else { + loglevelLogger.setLevel(loglevelLogger.levels.WARN); } - if (isCsdlConfig(this.config)) { - const { schema } = this.createSchema({ csdl: this.config.csdl }); - this.schema = schema; - this.queryPlannerPointer = getQueryPlanner(this.config.csdl); - } + return loglevelLogger; + } - this.initializeQueryPlanStore(); - - // this will be overwritten if the config provides experimental_updateServiceDefinitions - this.updateServiceDefinitions = this.loadServiceDefinitions; - - if (config) { - this.updateServiceDefinitions = - config.experimental_updateServiceDefinitions || - this.updateServiceDefinitions; - // set up experimental observability callbacks - this.experimental_didResolveQueryPlan = - config.experimental_didResolveQueryPlan; - this.experimental_didFailComposition = - config.experimental_didFailComposition; - this.experimental_didUpdateComposition = - config.experimental_didUpdateComposition; - - this.experimental_approximateQueryPlanStoreMiB = - config.experimental_approximateQueryPlanStoreMiB; - - if ( - isManagedConfig(config) && - config.experimental_pollInterval && - config.experimental_pollInterval < 10000 - ) { - this.experimental_pollInterval = 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 your team, please contact support.', - ); - } else { - this.experimental_pollInterval = config.experimental_pollInterval; - } + private initQueryPlanStore(approximateQueryPlanStoreMiB?: number) { + return new InMemoryLRUCache({ + // Create ~about~ a 30MiB InMemoryLRUCache. This is less than precise + // since the technique to calculate the size of a DocumentNode is + // only using JSON.stringify on the DocumentNode (and thus doesn't account + // for unicode characters, etc.), but it should do a reasonable job at + // providing a caching document store for most operations. + maxSize: Math.pow(2, 20) * (approximateQueryPlanStoreMiB || 30), + sizeCalculator: approximateObjectSize, + }); + } - // Warn against using the pollInterval and a serviceList simultaneously - if (config.experimental_pollInterval && isRemoteConfig(config)) { - this.logger.warn( - '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.', - ); - } + private issueDynamicWarningsIfApplicable() { + // 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.experimental_pollInterval = 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 ' + + 'your team, please contact support.', + ); + } - if (config.fetcher) { - this.fetcher = config.fetcher; - } + // Warn against using the pollInterval and a serviceList simultaneously + if (this.config.experimental_pollInterval && isRemoteConfig(this.config)) { + this.logger.warn( + '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.', + ); } } - public async load(options?: { apollo?: ApolloConfig; engine?: GraphQLServiceEngineConfig }) { + public async load(options?: { + apollo?: ApolloConfig; + engine?: GraphQLServiceEngineConfig; + }) { if (options?.apollo) { this.apolloConfig = options.apollo; } else if (options?.engine) { @@ -348,35 +251,61 @@ export class ApolloGateway implements GraphQLService { keyHash: options.engine.apiKeyHash, graphId: options.engine.graphId, graphVariant: options.engine.graphVariant || 'current', - } + }; } - await this.updateComposition(); - if ( - (isManagedConfig(this.config) || this.experimental_pollInterval) && - !this.pollingTimer - ) { - this.pollServices(); - } + this.maybeWarnOnConflictingConfig(); - const mode = isManagedConfig(this.config) ? 'managed' : 'unmanaged'; + // Handles initial assignment of `this.schema`, `this.queryPlannerPointer` + isStaticConfig(this.config) + ? this.loadStatic(this.config) + : await this.loadDynamic(); + const mode = isManagedConfig(this.config) ? 'managed' : 'unmanaged'; this.logger.info( `Gateway successfully loaded schema.\n\t* Mode: ${mode}${ - (this.apolloConfig && this.apolloConfig.graphId) + this.apolloConfig && this.apolloConfig.graphId ? `\n\t* Service: ${this.apolloConfig.graphId}@${this.apolloConfig.graphVariant}` : '' }`, ); return { - // we know this will be here since we're awaiting this.updateComposition - // before here which sets this.schema schema: this.schema!, - executor: this.executor, + executor: this.executor }; } + // Synchronously load a statically configured schema, update class instance's + // schema and query planner. + private loadStatic(config: StaticGatewayConfig) { + const schemaConstructionOpts = isLocalConfig(config) + ? { serviceList: config.localServiceList } + : { csdl: config.csdl }; + + const { schema, composedSdl } = this.createSchema(schemaConstructionOpts); + + this.schema = schema; + this.parsedCsdl = parse(composedSdl); + this.queryPlannerPointer = getQueryPlanner(composedSdl); + } + + // Asynchronously load a dynamically configured schema. `this.updateComposition` + // is responsible for updating the class instance's schema and query planner. + private async loadDynamic() { + await this.updateComposition(); + if (this.shouldBeginPolling()) { + this.pollServices(); + } + } + + private shouldBeginPolling() { + return ( + (isManagedConfig(this.config) || this.experimental_pollInterval) && + !this.pollingTimer + ); + } + protected async updateComposition(): Promise { let result: Await>; this.logger.debug('Checking service definitions...'); @@ -384,8 +313,8 @@ export class ApolloGateway implements GraphQLService { result = await this.updateServiceDefinitions(this.config); } catch (e) { this.logger.error( - "Error checking for changes to service definitions: " + - (e && e.message || e) + 'Error checking for changes to service definitions: ' + + ((e && e.message) || e), ); throw e; } @@ -404,7 +333,7 @@ export class ApolloGateway implements GraphQLService { const previousCompositionMetadata = this.compositionMetadata; if (previousSchema) { - this.logger.info("New service definitions were found."); + this.logger.info('New service definitions were found.'); } // Run service health checks before we commit and update the new schema. @@ -428,7 +357,8 @@ export class ApolloGateway implements GraphQLService { } catch (e) { this.logger.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.' + e + 'The gateway will continue to operate with the previous schema and reattempt updates.' + + e, ); throw e; } @@ -445,19 +375,23 @@ export class ApolloGateway implements GraphQLService { if (!composedSdl) { this.logger.error( - "A valid schema couldn't be composed. Falling back to previous schema." - ) + "A valid schema couldn't be composed. Falling back to previous schema.", + ); } else { this.schema = schema; this.queryPlannerPointer = getQueryPlanner(composedSdl); // Notify the schema listeners of the updated schema try { - this.onSchemaChangeListeners.forEach(listener => listener(this.schema!)); + this.onSchemaChangeListeners.forEach((listener) => + listener(this.schema!), + ); } catch (e) { this.logger.error( "An error was thrown from an 'onSchemaChange' listener. " + - "The schema will still update: " + (e && e.message || e)); + 'The schema will still update: ' + + ((e && e.message) || e), + ); } if (this.experimental_didUpdateComposition) { @@ -502,7 +436,7 @@ export class ApolloGateway implements GraphQLService { Object.entries(serviceMap).map(([name, { dataSource }]) => dataSource .process({ request: { query: HEALTH_CHECK_QUERY }, context: {} }) - .then(response => ({ name, response })), + .then((response) => ({ name, response })), ), ); } @@ -511,7 +445,7 @@ export class ApolloGateway implements GraphQLService { input: { serviceList: ServiceDefinition[] } | { csdl: string }, ) { if ('serviceList' in input) { - return this.createSchemaFromServiceList(input.serviceList) + return this.createSchemaFromServiceList(input.serviceList); } else { return this.createSchemaFromCsdl(input.csdl); } @@ -537,7 +471,10 @@ export class ApolloGateway implements GraphQLService { }), }); } - throw new GraphQLSchemaValidationError(errors); + 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 { composedSdl } = compositionResult; this.createServices(serviceList); @@ -612,7 +549,7 @@ export class ApolloGateway implements GraphQLService { if (this.pollingTimer) clearTimeout(this.pollingTimer); // Sleep for the specified pollInterval before kicking off another round of polling - await new Promise(res => { + await new Promise((res) => { this.pollingTimer = setTimeout( () => res(), this.experimental_pollInterval || 10000, @@ -626,7 +563,7 @@ export class ApolloGateway implements GraphQLService { try { await this.updateComposition(); } catch (err) { - this.logger.error(err && err.message || err); + this.logger.error((err && err.message) || err); } this.pollServices(); @@ -673,47 +610,10 @@ export class ApolloGateway implements GraphQLService { } protected async loadServiceDefinitions( - config: GatewayConfig, + config: RemoteGatewayConfig | ManagedGatewayConfig, ): ReturnType { - const canUseManagedConfig = - this.apolloConfig?.graphId && this.apolloConfig?.keyHash; - // This helper avoids the repetition of options in the two cases this method - // is invoked below. Only call it if canUseManagedConfig is true - // (which makes its uses of ! safe) - const getManagedConfig = () => { - return getServiceDefinitionsFromStorage({ - graphId: this.apolloConfig!.graphId!, - apiKeyHash: this.apolloConfig!.keyHash!, - graphVariant: this.apolloConfig!.graphVariant, - federationVersion: - (config as ManagedGatewayConfig).federationVersion || 1, - fetcher: this.fetcher, - }); - }; - - if (isLocalConfig(config) || isRemoteConfig(config) || isCsdlConfig(config)) { - if (canUseManagedConfig && !this.warnedStates.remoteWithLocalConfig) { - // Only display this warning once per start-up. - this.warnedStates.remoteWithLocalConfig = true; - // This error helps avoid common misconfiguration. - // We don't await this because a local configuration should assume - // remote is unavailable for one reason or another. - getManagedConfig().then(() => { - this.logger.warn( - "A local gateway configuration is overriding a managed federation " + - "configuration. To use the managed " + - "configuration, do not specify a service list or csdl locally.", - ); - }).catch(() => {}); // Don't mind errors if managed config is missing. - } - } - - if (isLocalConfig(config) || isCsdlConfig(config)) { - return { isNewSchema: false }; - } - if (isRemoteConfig(config)) { - const serviceList = config.serviceList.map(serviceDefinition => ({ + const serviceList = config.serviceList.map((serviceDefinition) => ({ ...serviceDefinition, dataSource: this.createAndCacheDataSource(serviceDefinition), })); @@ -727,13 +627,50 @@ export class ApolloGateway implements GraphQLService { }); } + const canUseManagedConfig = + this.apolloConfig?.graphId && this.apolloConfig?.keyHash; if (!canUseManagedConfig) { throw new Error( - 'When `serviceList` is not set, an Apollo configuration must be provided. See https://www.apollographql.com/docs/apollo-server/federation/managed-federation/ for more information.', + '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`, `csdl`, and `experimental_updateServiceDefinitions`.', ); } - return getManagedConfig(); + return getServiceDefinitionsFromStorage({ + graphId: this.apolloConfig!.graphId!, + apiKeyHash: this.apolloConfig!.keyHash!, + graphVariant: this.apolloConfig!.graphVariant, + federationVersion: config.federationVersion || 1, + fetcher: this.fetcher, + }); + } + + private maybeWarnOnConflictingConfig() { + const canUseManagedConfig = + this.apolloConfig?.graphId && this.apolloConfig?.keyHash; + + // This might be a bit confusing just by reading, but `!isManagedConfig` just + // means it's any of the other types of config. If it's any other config _and_ + // we have a studio config available (`canUseManagedConfig`) then we have a + // conflict. + if ( + !isManagedConfig(this.config) && + canUseManagedConfig && + !this.warnedStates.remoteWithLocalConfig + ) { + // Only display this warning once per start-up. + this.warnedStates.remoteWithLocalConfig = true; + // This error helps avoid common misconfiguration. + // We don't await this because a local configuration should assume + // remote is unavailable for one reason or another. + this.logger.warn( + 'A local gateway configuration is overriding a managed federation ' + + 'configuration. To use the managed ' + + 'configuration, do not specify a service list or csdl locally.', + ); + } } // XXX Nothing guarantees that the only errors thrown or returned in @@ -791,7 +728,7 @@ export class ApolloGateway implements GraphQLService { // is returning a non-native `Promise` (e.g. Bluebird, etc.). Promise.resolve( this.queryPlanStore.set(queryPlanStoreKey, queryPlan), - ).catch(err => + ).catch((err) => this.logger.warn( 'Could not store queryPlan' + ((err && err.message) || err), ), @@ -876,20 +813,6 @@ export class ApolloGateway implements GraphQLService { return errors || []; } - private initializeQueryPlanStore(): void { - this.queryPlanStore = new InMemoryLRUCache({ - // Create ~about~ a 30MiB InMemoryLRUCache. This is less than precise - // since the technique to calculate the size of a DocumentNode is - // only using JSON.stringify on the DocumentNode (and thus doesn't account - // for unicode characters, etc.), but it should do a reasonable job at - // providing a caching document store for most operations. - maxSize: - Math.pow(2, 20) * - (this.experimental_approximateQueryPlanStoreMiB || 30), - sizeCalculator: approximateObjectSize, - }); - } - public async stop() { if (this.pollingTimer) { clearTimeout(this.pollingTimer); @@ -931,5 +854,13 @@ export { buildOperationContext, QueryPlan, ServiceMap, + Experimental_DidFailCompositionCallback, + Experimental_DidResolveQueryPlanCallback, + Experimental_DidUpdateCompositionCallback, + Experimental_UpdateServiceDefinitions, + GatewayConfig, + ServiceEndpointDefinition, + Experimental_CompositionInfo, }; + export * from './datasources'; diff --git a/gateway-js/src/loadServicesFromRemoteEndpoint.ts b/gateway-js/src/loadServicesFromRemoteEndpoint.ts index c4dd8b9f3..8f3035f67 100644 --- a/gateway-js/src/loadServicesFromRemoteEndpoint.ts +++ b/gateway-js/src/loadServicesFromRemoteEndpoint.ts @@ -2,7 +2,8 @@ import { GraphQLRequest } from 'apollo-server-types'; import { parse } from 'graphql'; import { Headers, HeadersInit } from 'node-fetch'; import { GraphQLDataSource } from './datasources/types'; -import { Experimental_UpdateServiceDefinitions, SERVICE_DEFINITION_QUERY } from './'; +import { SERVICE_DEFINITION_QUERY } from './'; +import { Experimental_UpdateServiceDefinitions } from './config'; import { ServiceDefinition } from '@apollo/federation'; export async function getServiceDefinitionsFromRemoteEndpoint({ diff --git a/gateway-js/src/loadServicesFromStorage.ts b/gateway-js/src/loadServicesFromStorage.ts index 4bc01e6dc..17f23980d 100644 --- a/gateway-js/src/loadServicesFromStorage.ts +++ b/gateway-js/src/loadServicesFromStorage.ts @@ -1,6 +1,6 @@ import { fetch } from 'apollo-server-env'; import { parse } from 'graphql'; -import { Experimental_UpdateServiceDefinitions } from '.'; +import { Experimental_UpdateServiceDefinitions } from './config'; interface LinkFileResult { configPath: string;