From 23c2a614f31aa451d3d431ffa42f222a19f92d97 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 4 Aug 2020 13:26:04 -0700 Subject: [PATCH] Move basic configuration from engine to apollo Currently, the `engine` option to `new ApolloServer` contains a mishmash of data including the Apollo API key, the graph variant, configuration options for usage reporting, configuration options for schema reporting, and more. Also, Engine is no longer the name of the Apollo commercial product (the web app is now called Studio). This is the first PR in a series of PRs which will replace this `engine` option. Future PRs will refactor `EngineReportingAgent` into a set of smaller `ApolloServerPlugin`s. This PR addresses the basic configuration: API key, graph ID, and graph variant. It adds a new option called `apollo` to `new ApolloServer` of type `ApolloConfigInput`, where you can specify the API key, graph ID, and graph variant. It adds a second type `ApolloConfig`, which is similar except it also contains the API key hash, and the graph variant here is always defined ('current' by default). A single new internal determineApolloConfig function is used to produce an `ApolloConfig` object at the top of the ApolloServer constructor, consolidating a bunch of previously scattered code. (This also consolidates some warnings. For example, we now consistently warn when you create an ApolloServer and an API key is available but a variant is not specified, instead of doing that in just ApolloGateway.) It is an error to pass both `apollo` and `engine` to `new ApolloServer`. This does mean that if you want to configure any of the other aspects of reporting, you can not yet use the `apollo` option. I do not expect this PR to be released until all of that configuration can be done via separate plugins as mentioned above. This `ApolloConfig` object is now passed to `ApolloServerPlugin`s in `serverWillStart`, to `ApolloGateway` in `load`, and to the `EngineReportingAgent` constructor. The former two still also receive `engine` options for backwards compatibility. `ApolloGateway` will be updated to be able to read both `apollo` and `engine` in https://github.com/apollographql/federation/pull/148 (Note that I've intentionally applied `prettier` to apollo-engine-reporting's agent.ts, as that package's files have generally been kept prettier-clean. That file will be deleted soon anyway.) --- packages/apollo-engine-reporting/src/agent.ts | 160 +++++------------- .../apollo-server-core/src/ApolloServer.ts | 80 ++++----- .../src/__tests__/ApolloServerBase.test.ts | 39 +++-- .../src/determineApolloConfig.ts | 109 ++++++++++++ packages/apollo-server-core/src/types.ts | 7 +- .../src/utils/pluginTestHarness.ts | 3 + .../src/ApolloServer.ts | 10 ++ .../ApolloServerPluginOperationRegistry.ts | 48 +++++- ...polloServerPluginOperationRegistry.test.ts | 1 + .../src/__tests__/agent.test.ts | 10 +- .../src/__tests__/helpers.test-helpers.ts | 8 +- .../src/agent.ts | 31 +--- .../src/common.ts | 2 +- packages/apollo-server-types/src/index.ts | 27 ++- 14 files changed, 323 insertions(+), 212 deletions(-) create mode 100644 packages/apollo-server-core/src/determineApolloConfig.ts diff --git a/packages/apollo-engine-reporting/src/agent.ts b/packages/apollo-engine-reporting/src/agent.ts index 5105bfbcd93..7d8683d0a97 100644 --- a/packages/apollo-engine-reporting/src/agent.ts +++ b/packages/apollo-engine-reporting/src/agent.ts @@ -22,6 +22,8 @@ import { GraphQLRequestContextDidEncounterErrors, GraphQLRequestContextDidResolveOperation, Logger, + ApolloConfig, + WithRequired, } from 'apollo-server-types'; import { InMemoryLRUCache } from 'apollo-server-caching'; import { defaultEngineReportingSignature } from 'apollo-graphql'; @@ -30,8 +32,6 @@ import { reportingLoop, SchemaReporter } from './schemaReporter'; import { v4 as uuidv4 } from 'uuid'; import { createHash } from 'crypto'; -let warnedOnDeprecatedApiKey = false; - export interface ClientInfo { clientName?: string; clientVersion?: string; @@ -69,75 +69,6 @@ export type GenerateClientInfo = ( requestContext: GraphQLRequestContext, ) => ClientInfo; -// AS3: Drop support for deprecated `ENGINE_API_KEY`. -export function getEngineApiKey({ - engine, - skipWarn = false, - logger = console, -}: { - engine: EngineReportingOptions | boolean | undefined; - skipWarn?: boolean; - logger?: Logger; -}) { - if (typeof engine === 'object') { - if (engine.apiKey) { - return engine.apiKey; - } - } - const legacyApiKeyFromEnv = process.env.ENGINE_API_KEY; - const apiKeyFromEnv = process.env.APOLLO_KEY; - - if (legacyApiKeyFromEnv && apiKeyFromEnv && !skipWarn) { - logger.warn( - 'Using `APOLLO_KEY` since `ENGINE_API_KEY` (deprecated) is also set in the environment.', - ); - } - if (legacyApiKeyFromEnv && !warnedOnDeprecatedApiKey && !skipWarn) { - logger.warn( - '[deprecated] The `ENGINE_API_KEY` environment variable has been renamed to `APOLLO_KEY`.', - ); - warnedOnDeprecatedApiKey = true; - } - return apiKeyFromEnv || legacyApiKeyFromEnv || ''; -} - -// AS3: Drop support for deprecated `ENGINE_SCHEMA_TAG`. -export function getEngineGraphVariant( - engine: EngineReportingOptions | boolean | undefined, - logger: Logger = console, -): string | undefined { - if (engine === false) { - return; - } else if ( - typeof engine === 'object' && - (engine.graphVariant || engine.schemaTag) - ) { - if (engine.graphVariant && engine.schemaTag) { - throw new Error( - 'Cannot set both engine.graphVariant and engine.schemaTag. Please use engine.graphVariant.', - ); - } - if (engine.schemaTag) { - logger.warn( - '[deprecated] The `schemaTag` property within `engine` configuration has been renamed to `graphVariant`.', - ); - } - return engine.graphVariant || engine.schemaTag; - } else { - if (process.env.ENGINE_SCHEMA_TAG) { - logger.warn( - '[deprecated] The `ENGINE_SCHEMA_TAG` environment variable has been renamed to `APOLLO_GRAPH_VARIANT`.', - ); - } - if (process.env.ENGINE_SCHEMA_TAG && process.env.APOLLO_GRAPH_VARIANT) { - throw new Error( - '`APOLLO_GRAPH_VARIANT` and `ENGINE_SCHEMA_TAG` (deprecated) environment variables must not both be set.', - ); - } - return process.env.APOLLO_GRAPH_VARIANT || process.env.ENGINE_SCHEMA_TAG; - } -} - export interface EngineReportingOptions { /** * API key for the service. Get this from @@ -429,9 +360,8 @@ class ReportData { // to the Engine server. export class EngineReportingAgent { private readonly options: EngineReportingOptions; - private readonly apiKey: string; + private readonly apolloConfig: WithRequired; private readonly logger: Logger = console; - private readonly graphVariant: string; private readonly reportDataByExecutableSchemaId: { [executableSchemaId: string]: ReportData | undefined; @@ -452,29 +382,25 @@ export class EngineReportingAgent { private readonly tracesEndpointUrl: string; readonly schemaReport: boolean; - public constructor(options: EngineReportingOptions = {}) { + public constructor( + options: EngineReportingOptions = {}, + apolloConfig: WithRequired, + ) { this.options = options; - this.apiKey = getEngineApiKey({ - engine: this.options, - skipWarn: false, - logger: this.logger, - }); if (options.logger) this.logger = options.logger; this.bootId = uuidv4(); - this.graphVariant = getEngineGraphVariant(options, this.logger) || ''; - if (!this.apiKey) { - throw new Error( - `To use EngineReportingAgent, you must specify an API key via the apiKey option or the APOLLO_KEY environment variable.`, - ); + if (!apolloConfig.key) { + throw new Error('Missing API key.'); } + this.apolloConfig = apolloConfig; if (options.experimental_schemaReporting !== undefined) { this.logger.warn( [ '[deprecated] The "experimental_schemaReporting" option has been', - 'renamed to "reportSchema"' - ].join(' ') + 'renamed to "reportSchema"', + ].join(' '), ); if (options.reportSchema === undefined) { options.reportSchema = options.experimental_schemaReporting; @@ -485,11 +411,12 @@ export class EngineReportingAgent { this.logger.warn( [ '[deprecated] The "experimental_overrideReportedSchema" option has', - 'been renamed to "overrideReportedSchema"' - ].join(' ') + 'been renamed to "overrideReportedSchema"', + ].join(' '), ); if (options.overrideReportedSchema === undefined) { - options.overrideReportedSchema = options.experimental_overrideReportedSchema; + options.overrideReportedSchema = + options.experimental_overrideReportedSchema; } } @@ -497,18 +424,19 @@ export class EngineReportingAgent { this.logger.warn( [ '[deprecated] The "experimental_schemaReportingInitialDelayMaxMs"', - 'option has been renamed to "schemaReportingInitialDelayMaxMs"' - ].join(' ') + 'option has been renamed to "schemaReportingInitialDelayMaxMs"', + ].join(' '), ); if (options.schemaReportingInitialDelayMaxMs === undefined) { - options.schemaReportingInitialDelayMaxMs = options.experimental_schemaReportingInitialDelayMaxMs; + options.schemaReportingInitialDelayMaxMs = + options.experimental_schemaReportingInitialDelayMaxMs; } } if (options.reportSchema !== undefined) { this.schemaReport = options.reportSchema; } else { - this.schemaReport = process.env.APOLLO_SCHEMA_REPORTING === "true" + this.schemaReport = process.env.APOLLO_SCHEMA_REPORTING === 'true'; } // Since calculating the signature for Engine reporting is potentially an @@ -567,7 +495,7 @@ export class EngineReportingAgent { if (existing) { return existing; } - const reportData = new ReportData(executableSchemaId, this.graphVariant); + const reportData = new ReportData(executableSchemaId, this.apolloConfig.graphVariant); this.reportDataByExecutableSchemaId[executableSchemaId] = reportData; return reportData; } @@ -636,7 +564,7 @@ export class EngineReportingAgent { public async sendAllReports(): Promise { await Promise.all( - Object.keys(this.reportDataByExecutableSchemaId).map(id => + Object.keys(this.reportDataByExecutableSchemaId).map((id) => this.sendReport(id), ), ); @@ -702,7 +630,7 @@ export class EngineReportingAgent { method: 'POST', headers: { 'user-agent': 'apollo-engine-reporting', - 'x-api-key': this.apiKey, + 'x-api-key': this.apolloConfig.key!, 'content-encoding': 'gzip', }, body: compressed, @@ -711,8 +639,9 @@ export class EngineReportingAgent { if (curResponse.status >= 500 && curResponse.status < 600) { throw new Error( - `HTTP status ${curResponse.status}, ${(await curResponse.text()) || - '(no body)'}`, + `HTTP status ${curResponse.status}, ${ + (await curResponse.text()) || '(no body)' + }`, ); } else { return curResponse; @@ -763,14 +692,14 @@ export class EngineReportingAgent { this.logger.info('Schema to report has been overridden'); } if (this.options.schemaReportingInitialDelayMaxMs !== undefined) { - this.logger.info(`Schema reporting max initial delay override: ${ - this.options.schemaReportingInitialDelayMaxMs - } ms`); + this.logger.info( + `Schema reporting max initial delay override: ${this.options.schemaReportingInitialDelayMaxMs} ms`, + ); } if (this.options.schemaReportingUrl !== undefined) { - this.logger.info(`Schema reporting URL override: ${ - this.options.schemaReportingUrl - }`); + this.logger.info( + `Schema reporting URL override: ${this.options.schemaReportingUrl}`, + ); } if (this.currentSchemaReporter) { this.currentSchemaReporter.stop(); @@ -778,7 +707,7 @@ export class EngineReportingAgent { const serverInfo = { bootId: this.bootId, - graphVariant: this.graphVariant, + graphVariant: this.apolloConfig.graphVariant, // The infra environment in which this edge server is running, e.g. localhost, Kubernetes // Length must be <= 256 characters. platform: process.env.APOLLO_SERVER_PLATFORM || 'local', @@ -796,21 +725,20 @@ export class EngineReportingAgent { }; this.logger.info( - `Schema reporting EdgeServerInfo: ${JSON.stringify(serverInfo)}` - ) + `Schema reporting EdgeServerInfo: ${JSON.stringify(serverInfo)}`, + ); // Jitter the startup between 0 and 10 seconds const delay = Math.floor( - Math.random() * - (this.options.schemaReportingInitialDelayMaxMs || 10_000), + Math.random() * (this.options.schemaReportingInitialDelayMaxMs || 10_000), ); const schemaReporter = new SchemaReporter( serverInfo, executableSchema, - this.apiKey, + this.apolloConfig.key!, this.options.schemaReportingUrl, - this.logger + this.logger, ); const fallbackReportingDelayInMs = 20_000; @@ -818,7 +746,7 @@ export class EngineReportingAgent { this.currentSchemaReporter = schemaReporter; const logger = this.logger; - setTimeout(function() { + setTimeout(function () { reportingLoop(schemaReporter, logger, false, fallbackReportingDelayInMs); }, delay); } @@ -896,7 +824,7 @@ export class EngineReportingAgent { // either the request-specific logger on the request context (if available) // or to the `logger` that was passed into `EngineReportingOptions` which // is provided in the `EngineReportingAgent` constructor options. - this.signatureCache.set(cacheKey, generatedSignature).catch(err => { + this.signatureCache.set(cacheKey, generatedSignature).catch((err) => { logger.warn( 'Could not store signature cache. ' + (err && err.message) || err, ); @@ -907,14 +835,16 @@ export class EngineReportingAgent { public async sendAllReportsAndReportErrors(): Promise { await Promise.all( - Object.keys(this.reportDataByExecutableSchemaId).map(executableSchemaId => + Object.keys( + this.reportDataByExecutableSchemaId, + ).map((executableSchemaId) => this.sendReportAndReportErrors(executableSchemaId), ), ); } private sendReportAndReportErrors(executableSchemaId: string): Promise { - return this.sendReport(executableSchemaId).catch(err => { + return this.sendReport(executableSchemaId).catch((err) => { // This catch block is primarily intended to catch network errors from // the retried request itself, which include network errors and non-2xx // HTTP errors. diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index eda7a85adce..0d22ef72941 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -66,7 +66,6 @@ import { import { generateSchemaHash } from './utils/schemaHash'; import { isDirectiveDefined } from './utils/isDirectiveDefined'; -import createSHA from './utils/createSHA'; import { processGraphQLRequest, GraphQLRequestContext, @@ -77,14 +76,15 @@ import { import { Headers } from 'apollo-server-env'; import { buildServiceDefinition } from '@apollographql/apollo-tools'; import { plugin as pluginTracing } from "apollo-tracing"; -import { Logger, SchemaHash, ValueOrPromise } from "apollo-server-types"; +import { Logger, SchemaHash, ValueOrPromise, ApolloConfig, WithRequired } from "apollo-server-types"; import { plugin as pluginCacheControl, CacheControlExtensionOptions, } from 'apollo-cache-control'; -import { getEngineApiKey, getEngineGraphVariant } from "apollo-engine-reporting/dist/agent"; import { cloneObject } from "./runHttpQuery"; import isNodeLike from './utils/isNodeLike'; +import { determineApolloConfig } from './determineApolloConfig'; +import { federatedPlugin, EngineReportingAgent } from 'apollo-engine-reporting'; const NoIntrospection = (context: ValidationContext) => ({ Field(node: FieldDefinitionNode) { @@ -99,15 +99,6 @@ const NoIntrospection = (context: ValidationContext) => ({ }, }); -function getEngineServiceId(engine: Config['engine'], logger: Logger): string | undefined { - const engineApiKey = getEngineApiKey({engine, skipWarn: true, logger} ); - if (engineApiKey) { - return engineApiKey.split(':', 2)[1]; - } - - return; -} - const forbidUploadsForTesting = process && process.env.NODE_ENV === 'test' && !runtimeSupportsUploads; @@ -132,9 +123,8 @@ export class ApolloServerBase { public requestOptions: Partial> = Object.create(null); private context?: Context | ContextFunction; - private engineReportingAgent?: import('apollo-engine-reporting').EngineReportingAgent; - private engineServiceId?: string; - private engineApiKeyHash?: string; + private engineReportingAgent?: EngineReportingAgent; + private apolloConfig: ApolloConfig; protected plugins: ApolloServerPlugin[] = []; protected subscriptionServerOptions?: SubscriptionServerOptions; @@ -171,7 +161,6 @@ export class ApolloServerBase { mocks, mockEntireSchema, extensions, - engine, subscriptions, uploads, playground, @@ -180,9 +169,20 @@ export class ApolloServerBase { cacheControl, experimental_approximateDocumentStoreMiB, stopOnTerminationSignals, + apollo, + engine, ...requestOptions } = config; + if (engine !== undefined) { + // FIXME(no-engine): finish implementing backwards-compatibility `engine` + // mode and warn about deprecation + if (apollo) { + throw new Error("You cannot provide both `engine` and `apollo` to `new ApolloServer()`. " + + "For details on how to migrate all of your options out of `engine`, see FIXME(no-engine) URL MISSING"); + } + } + // Setup logging facilities if (config.logger) { this.logger = config.logger; @@ -205,6 +205,8 @@ export class ApolloServerBase { this.logger = loglevelLogger; } + this.apolloConfig = determineApolloConfig(apollo, engine, this.logger); + if (gateway && (modules || schema || typeDefs || resolvers)) { throw new Error( 'Cannot define both `gateway` and any of: `modules`, `schema`, `typeDefs`, or `resolvers`', @@ -299,24 +301,17 @@ export class ApolloServerBase { } } - // In an effort to avoid over-exposing the API key itself, extract the - // service ID from the API key for plugins which only needs service ID. - // The truthiness of this value can also be used in other forks of logic - // related to Engine, as is the case with EngineReportingAgent just below. - this.engineServiceId = getEngineServiceId(engine, this.logger); - const apiKey = getEngineApiKey({engine, skipWarn: true, logger: this.logger}); - if (apiKey) { - this.engineApiKeyHash = createSHA('sha512') - .update(apiKey) - .digest('hex'); - } - - if (this.engineServiceId) { - const { EngineReportingAgent } = require('apollo-engine-reporting'); + if (this.apolloConfig.key) { + // FIXME(no-engine) Eliminate EngineReportingAgent entirely. this.engineReportingAgent = new EngineReportingAgent( typeof engine === 'object' ? engine : Object.create({ logger: this.logger, }), + // This isn't part of EngineReportingOptions because it's generated + // internally by ApolloServer, not part of the end-user options... and + // hopefully EngineReportingAgent will be eliminated before this code + // is shipped anyway. + this.apolloConfig as WithRequired, ); // Don't add the extension here (we want to add it later in generateSchemaDerivedData). } @@ -395,9 +390,9 @@ export class ApolloServerBase { if ( typeof stopOnTerminationSignals === 'boolean' ? stopOnTerminationSignals - : typeof this.config.engine === 'object' && - typeof this.config.engine.handleSignals === 'boolean' - ? this.config.engine.handleSignals + : typeof engine === 'object' && + typeof engine.handleSignals === 'boolean' + ? engine.handleSignals : isNodeLike && process.env.NODE_ENV !== 'test' ) { const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; @@ -425,7 +420,6 @@ export class ApolloServerBase { private initSchema(): GraphQLSchema | Promise { const { gateway, - engine, schema, modules, typeDefs, @@ -445,13 +439,13 @@ export class ApolloServerBase { ), ); - const graphVariant = getEngineGraphVariant(engine, this.logger); + // For backwards compatibility with old versions of @apollo/gateway. const engineConfig = - this.engineApiKeyHash && this.engineServiceId + this.apolloConfig.keyHash && this.apolloConfig.graphId ? { - apiKeyHash: this.engineApiKeyHash, - graphId: this.engineServiceId, - ...(graphVariant && { graphVariant }), + apiKeyHash: this.apolloConfig.keyHash, + graphId: this.apolloConfig.graphId, + graphVariant: this.apolloConfig.graphVariant, } : undefined; @@ -461,7 +455,7 @@ export class ApolloServerBase { // a federated schema! this.requestOptions.executor = gateway.executor; - return gateway.load({ engine: engineConfig }) + return gateway.load({ apollo: this.apolloConfig, engine: engineConfig }) .then(config => config.schema) .catch(err => { // We intentionally do not re-throw the exact error from the gateway @@ -597,9 +591,10 @@ export class ApolloServerBase { logger: this.logger, schema: schema, schemaHash: schemaHash, + apollo: this.apolloConfig, engine: { - serviceID: this.engineServiceId, - apiKeyHash: this.engineApiKeyHash, + serviceID: this.apolloConfig.graphId, + apiKeyHash: this.apolloConfig.keyHash, }, }; @@ -838,7 +833,6 @@ export class ApolloServerBase { // We haven't configured this app to use Engine directly. But it looks like // we are a federated service backend, so we should be capable of including // our trace in a response extension if we are asked to by the gateway. - const { federatedPlugin } = require('apollo-engine-reporting'); const rewriteError = engine && typeof engine === 'object' ? engine.rewriteError : undefined; pluginsToInit.push(federatedPlugin({ rewriteError })); diff --git a/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts b/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts index 52013e02fe6..f7df01739e3 100644 --- a/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts +++ b/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts @@ -1,6 +1,7 @@ import { ApolloServerBase } from '../ApolloServer'; import { buildServiceDefinition } from '@apollographql/apollo-tools'; import gql from 'graphql-tag'; +import { Logger } from 'apollo-server-types'; const typeDefs = gql` type Query { @@ -45,7 +46,10 @@ describe('ApolloServerBase construction', () => { ).not.toThrow(); }); - it('spits out a deprecation warning when passed a schemaTag in construction', () => { + // FIXME(no-engine): This should be changed to check for a deprecation + // warning for any use of `engine` (which we can't really do until splitting + // out the plugins). + it.skip('spits out a deprecation warning when passed a schemaTag in construction', () => { const spyConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); expect( () => @@ -112,32 +116,47 @@ describe('environment variables', () => { it('constructs a reporting agent with the ENGINE_API_KEY (deprecated) environment variable and warns', async () => { // set the variables process.env.ENGINE_API_KEY = 'just:fake:stuff'; - const spyConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + const warn = jest.fn(); + const mockLogger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn, + error: jest.fn(), + }; const server = new ApolloServerBase({ typeDefs, - resolvers + resolvers, + apollo: { graphVariant: 'xxx' }, + logger: mockLogger, }); await server.stop(); - expect(spyConsoleWarn).toHaveBeenCalledTimes(1); - spyConsoleWarn.mockReset(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/deprecated.*ENGINE_API_KEY/); }); it('warns with both the legacy env var and new env var set', async () => { // set the variables process.env.ENGINE_API_KEY = 'just:fake:stuff'; process.env.APOLLO_KEY = 'also:fake:stuff'; - const spyConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + const warn = jest.fn(); + const mockLogger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn, + error: jest.fn(), + }; const server = new ApolloServerBase({ typeDefs, - resolvers + resolvers, + apollo: { graphVariant: 'xxx' }, + logger: mockLogger, }); await server.stop(); - // Once for deprecation, once for double-set - expect(spyConsoleWarn).toHaveBeenCalledTimes(2); - spyConsoleWarn.mockReset(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/Using.*APOLLO_KEY.*ENGINE_API_KEY/); }); }); diff --git a/packages/apollo-server-core/src/determineApolloConfig.ts b/packages/apollo-server-core/src/determineApolloConfig.ts new file mode 100644 index 00000000000..1148057eb7f --- /dev/null +++ b/packages/apollo-server-core/src/determineApolloConfig.ts @@ -0,0 +1,109 @@ +import { ApolloConfig, ApolloConfigInput, Logger } from 'apollo-server-types'; +import { EngineReportingOptions } from 'apollo-engine-reporting'; +import createSHA from './utils/createSHA'; + +// This function combines the newer `apollo` constructor argument, the older +// `engine` constructor argument, and some environment variables to come up +// with a full ApolloConfig. +// +// The caller ensures that only one of the two constructor arguments is actually +// provided and warns if `engine` was provided, but it is this function's job +// to warn if old environment variables are used. +export function determineApolloConfig( + input: ApolloConfigInput | undefined, + // For backwards compatibility. + // AS3: Drop support for deprecated 'engine'. + engine: EngineReportingOptions | boolean | undefined, + logger: Logger, +): ApolloConfig { + if (input && engine !== undefined) { + // There's a more helpful error in the actual ApolloServer constructor. + throw Error('Cannot pass both `apollo` and `engine`'); + } + const apolloConfig: ApolloConfig = { graphVariant: 'current' }; + + const { + APOLLO_KEY, + APOLLO_GRAPH_ID, + APOLLO_GRAPH_VARIANT, + // AS3: Drop support for deprecated `ENGINE_API_KEY` and `ENGINE_SCHEMA_TAG`. + ENGINE_API_KEY, + ENGINE_SCHEMA_TAG, + } = process.env; + + // Determine key. + if (input?.key) { + apolloConfig.key = input.key; + } else if (typeof engine === 'object' && engine.apiKey) { + apolloConfig.key = engine.apiKey; + } else if (APOLLO_KEY) { + if (ENGINE_API_KEY) { + logger.warn( + 'Using `APOLLO_KEY` since `ENGINE_API_KEY` (deprecated) is also set in the environment.', + ); + } + apolloConfig.key = APOLLO_KEY; + } else if (ENGINE_API_KEY) { + logger.warn( + '[deprecated] The `ENGINE_API_KEY` environment variable has been renamed to `APOLLO_KEY`.', + ); + apolloConfig.key = ENGINE_API_KEY; + } + + // Determine key hash. + if (apolloConfig.key) { + apolloConfig.keyHash = createSHA('sha512') + .update(apolloConfig.key) + .digest('hex'); + } + + // Determine graph id. + if (input?.graphId) { + apolloConfig.graphId = input.graphId; + } else if (APOLLO_GRAPH_ID) { + apolloConfig.graphId = APOLLO_GRAPH_ID; + } else if (apolloConfig.key) { + // This is the common case: if the given key is a graph token (starts with 'service:'), + // then use the service name written in the key. + const parts = apolloConfig.key.split(':', 2); + if (parts[0] === 'service') { + apolloConfig.graphId = parts[1]; + } + } + + // Determine variant. + if (input?.graphVariant) { + apolloConfig.graphVariant = input.graphVariant; + } else if (typeof engine === 'object' && engine.graphVariant) { + if (engine.schemaTag) { + throw new Error( + 'Cannot set more than one of apollo.graphVariant, ' + + 'engine.graphVariant, and engine.schemaTag. Please use apollo.graphVariant.', + ); + } + apolloConfig.graphVariant = engine.graphVariant; + } else if (typeof engine === 'object' && engine.schemaTag) { + // No need to warn here, because ApolloServer's constructor should warn about + // the existence of `engine` at all. + apolloConfig.graphVariant = engine.schemaTag; + } else if (APOLLO_GRAPH_VARIANT) { + if (ENGINE_SCHEMA_TAG) { + throw new Error( + '`APOLLO_GRAPH_VARIANT` and `ENGINE_SCHEMA_TAG` (deprecated) environment variables must not both be set.', + ); + } + apolloConfig.graphVariant = APOLLO_GRAPH_VARIANT; + } else if (ENGINE_SCHEMA_TAG) { + logger.warn( + '[deprecated] The `ENGINE_SCHEMA_TAG` environment variable has been renamed to `APOLLO_GRAPH_VARIANT`.', + ); + apolloConfig.graphVariant = ENGINE_SCHEMA_TAG; + } else if (apolloConfig.key) { + // Leave the value 'current' in apolloConfig.graphVariant. + // We warn if it looks like they're trying to use Apollo registry features, but there's + // no reason to warn if there's no key. + logger.warn('No graph variant provided. Defaulting to `current`.'); + } + + return apolloConfig; +} diff --git a/packages/apollo-server-core/src/types.ts b/packages/apollo-server-core/src/types.ts index 491be13340a..96f1b6a80dd 100644 --- a/packages/apollo-server-core/src/types.ts +++ b/packages/apollo-server-core/src/types.ts @@ -6,10 +6,12 @@ import { GraphQLParseOptions, } from 'graphql-tools'; import { + ApolloConfig, ValueOrPromise, GraphQLExecutor, GraphQLExecutionResult, GraphQLRequestContextExecutionDidStart, + ApolloConfigInput, } from 'apollo-server-types'; import { ConnectionContext } from 'subscriptions-transport-ws'; // The types for `ws` use `export = WebSocket`, so we'll use the @@ -90,6 +92,7 @@ export type GraphQLServiceEngineConfig = { export interface GraphQLService { load(options: { + apollo?: ApolloConfig, engine?: GraphQLServiceEngineConfig; }): Promise; onSchemaChange(callback: SchemaChangeCallback): Unsubscriber; @@ -113,7 +116,6 @@ export interface Config extends BaseConfig { introspection?: boolean; mocks?: boolean | IMocks; mockEntireSchema?: boolean; - engine?: boolean | EngineReportingOptions; extensions?: Array<() => GraphQLExtension>; cacheControl?: CacheControlExtensionOptions | boolean; plugins?: PluginDefinition[]; @@ -125,8 +127,11 @@ export interface Config extends BaseConfig { gateway?: GraphQLService; experimental_approximateDocumentStoreMiB?: number; stopOnTerminationSignals?: boolean; + apollo?: ApolloConfigInput; + engine?: boolean | EngineReportingOptions; } +// Configuration for how Apollo Server talks to the Apollo registry. export interface FileUploadOptions { //Max allowed non-file multipart form field size in bytes; enough for your queries (default: 1 MB). maxFieldSize?: number; diff --git a/packages/apollo-server-core/src/utils/pluginTestHarness.ts b/packages/apollo-server-core/src/utils/pluginTestHarness.ts index af034f07e5e..30ce5d35554 100644 --- a/packages/apollo-server-core/src/utils/pluginTestHarness.ts +++ b/packages/apollo-server-core/src/utils/pluginTestHarness.ts @@ -105,6 +105,9 @@ export default async function pluginTestHarness({ logger: logger || console, schema, schemaHash, + apollo: { + graphVariant: 'current', + }, engine: {}, }); if (maybeServerListener && maybeServerListener.serverWillStop) { diff --git a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts index a1efd8ce159..e8dbace7cfa 100644 --- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -1001,6 +1001,7 @@ export function testApolloServer( validationRules: [validationRule], extensions: [() => new Extension()], engine: { + graphVariant: 'current', ...engineServer.engineOptions(), apiKey: 'service:my-app:secret', maxUncompressedReportSize: 1, @@ -1076,6 +1077,7 @@ export function testApolloServer( }, }, engine: { + graphVariant: 'current', ...engineServer.engineOptions(), apiKey: 'service:my-app:secret', maxUncompressedReportSize: 1, @@ -2460,6 +2462,7 @@ export function testApolloServer( resolvers: { Query: { something: () => 'hello' } }, engine: { apiKey: 'service:my-app:secret', + graphVariant: 'current', tracesEndpointUrl: fakeEngineUrl, reportIntervalMs: 1, maxAttempts: 3, @@ -3213,6 +3216,13 @@ export function testApolloServer( }); expect(optionsSpy).toHaveBeenLastCalledWith({ + apollo: { + key: 'service:tester:1234abc', + keyHash: + '0ca858e7fe8cffc01c5f1db917d2463b348b50d267427e54c1c8c99e557b242f4145930b949905ec430642467613610e471c40bb7a251b1e2248c399bb0498c4', + graphId: 'tester', + graphVariant: 'staging', + }, engine: { apiKeyHash: '0ca858e7fe8cffc01c5f1db917d2463b348b50d267427e54c1c8c99e557b242f4145930b949905ec430642467613610e471c40bb7a251b1e2248c399bb0498c4', diff --git a/packages/apollo-server-plugin-operation-registry/src/ApolloServerPluginOperationRegistry.ts b/packages/apollo-server-plugin-operation-registry/src/ApolloServerPluginOperationRegistry.ts index 0200d75e3bf..42ca4bc57c3 100644 --- a/packages/apollo-server-plugin-operation-registry/src/ApolloServerPluginOperationRegistry.ts +++ b/packages/apollo-server-plugin-operation-registry/src/ApolloServerPluginOperationRegistry.ts @@ -49,6 +49,8 @@ export interface Options { | boolean | ForbidUnregisteredOperationsPredicate; dryRun?: boolean; + // Deprecated; configure via `new ApolloServer({apollo: {graphVariant}})` + // or $APOLLO_GRAPH_VARIANT instead. graphVariant?: string; onUnregisteredOperation?: ( requestContext: GraphQLRequestContext, @@ -63,8 +65,6 @@ export interface Options { export default function plugin(options: Options = Object.create(null)) { let agent: Agent; let store: InMemoryLRUCache; - const graphVariant = - options.graphVariant || process.env.APOLLO_GRAPH_VARIANT || 'current'; // Setup logging facilities, scoped under the appropriate name. const logger = loglevel.getLogger(`apollo-server:${pluginName}`); @@ -95,20 +95,46 @@ for observability purposes, but all operations will be permitted.`, // time depending on the usecase. Object.freeze(options); + let graphVariant: string; + return (): ApolloServerPlugin => ({ async serverWillStart({ + apollo, engine, }: GraphQLServiceContext): Promise { + if (!apollo) { + // Older version of apollo-server-core that isn't passing 'apollo' yet. + apollo = { + graphId: engine.serviceID, + // no 'key' because it's not on this part of the API, but + // this plugin doesn't need it. + keyHash: engine.apiKeyHash, + graphVariant: process.env.APOLLO_GRAPH_VARIANT || 'current', + }; + } + // Deprecated way of passing variant directly to the plugin instead of + // just getting it from serverWillStart. + if (options.graphVariant) { + apollo = { + ...apollo, + graphVariant: options.graphVariant, + }; + } + + // Make available to requestDidStart. + graphVariant = apollo.graphVariant; logger.debug('Initializing operation registry plugin.'); - if (!engine || !engine.serviceID) { - const messageEngineConfigurationRequired = + const {graphId, keyHash} = apollo; + + if (!(graphId && keyHash)) { + const messageApolloConfigurationRequired = 'The Apollo API key must be set to use the operation registry.'; - throw new Error(`${pluginName}: ${messageEngineConfigurationRequired}`); + throw new Error(`${pluginName}: ${messageApolloConfigurationRequired}`); } logger.debug( - `Operation registry is configured for '${engine.serviceID}'.`); + `Operation registry is configured for '${apollo.graphId}'.`); // An LRU store with no `maxSize` is effectively an InMemoryStore and // exactly what we want for this purpose. @@ -117,8 +143,12 @@ for observability purposes, but all operations will be permitted.`, logger.debug('Initializing operation registry agent...'); agent = new Agent({ - graphVariant, - engine, + apollo: { + ...apollo, + // Convince TypeScript that these fields are not undefined. + graphId, + keyHash + }, store, logger, fetcher: options.fetcher, @@ -298,7 +328,7 @@ for observability purposes, but all operations will be permitted.`, Object.assign(error.extensions, { operationSignature: signature, exception: { - message: `Please register your operation with \`npx apollo client:push --tag="${graphVariant}"\`. See https://www.apollographql.com/docs/platform/operation-registry/ for more details.`, + message: `Please register your operation with \`npx apollo client:push --variant="${graphVariant}"\`. See https://www.apollographql.com/docs/platform/operation-registry/ for more details.`, }, }); throw error; diff --git a/packages/apollo-server-plugin-operation-registry/src/__tests__/ApolloServerPluginOperationRegistry.test.ts b/packages/apollo-server-plugin-operation-registry/src/__tests__/ApolloServerPluginOperationRegistry.test.ts index 79b02a2fd7e..f44fd73a77b 100644 --- a/packages/apollo-server-plugin-operation-registry/src/__tests__/ApolloServerPluginOperationRegistry.test.ts +++ b/packages/apollo-server-plugin-operation-registry/src/__tests__/ApolloServerPluginOperationRegistry.test.ts @@ -55,6 +55,7 @@ describe('Operation registry plugin', () => { const engineOptions: EngineReportingOptions = { apiKey, reportTiming: false, + graphVariant: 'current', }; const typeDefs = gql` type Query { diff --git a/packages/apollo-server-plugin-operation-registry/src/__tests__/agent.test.ts b/packages/apollo-server-plugin-operation-registry/src/__tests__/agent.test.ts index 8d73a3fdbb5..51a1f40aef0 100644 --- a/packages/apollo-server-plugin-operation-registry/src/__tests__/agent.test.ts +++ b/packages/apollo-server-plugin-operation-registry/src/__tests__/agent.test.ts @@ -157,7 +157,7 @@ describe('Agent', () => { expect(relevantLogs[0][0]).toBe( `Checking for manifest changes at ${urlResolve( fakeTestBaseUrl, - getOperationManifestUrl(genericServiceID, genericStorageSecret), + getOperationManifestUrl(genericServiceID, genericStorageSecret, 'current'), )}`, ); @@ -312,7 +312,7 @@ describe('Agent', () => { }); describe('When given a graphVariant', () => { - const graphVariant = 'main'; + const graphVariant = 'different'; const getOperationManifestRelativeUrl = ( ...args: Parameters ) => @@ -321,9 +321,11 @@ describe('Agent', () => { '', ); - it('fetches manifests for the corresponding schema tag', async () => { + it('fetches manifests for the corresponding variant', async () => { nockStorageSecret(genericServiceID, genericApiKeyHash); - const agent = createAgent({ graphVariant: graphVariant }); + const agent = createAgent({ + apollo: { ...defaultAgentOptions.apollo, graphVariant }, + }); const nockedManifest = nockBase() .get( getOperationManifestRelativeUrl( diff --git a/packages/apollo-server-plugin-operation-registry/src/__tests__/helpers.test-helpers.ts b/packages/apollo-server-plugin-operation-registry/src/__tests__/helpers.test-helpers.ts index 6158e9869bb..ec3907ec65e 100644 --- a/packages/apollo-server-plugin-operation-registry/src/__tests__/helpers.test-helpers.ts +++ b/packages/apollo-server-plugin-operation-registry/src/__tests__/helpers.test-helpers.ts @@ -20,10 +20,13 @@ export const genericApiKeyHash = 'someapikeyhash123'; export const defaultTestAgentPollSeconds = 60; export const defaultAgentOptions: AgentOptions = { - engine: { serviceID: genericServiceID, apiKeyHash: genericApiKeyHash }, + apollo: { + graphId: genericServiceID, + keyHash: genericApiKeyHash, + graphVariant: 'current', + }, store: defaultStore(), pollSeconds: defaultTestAgentPollSeconds, - graphVariant: 'current', }; // Each nock is good for exactly one request! @@ -69,6 +72,7 @@ export function getOperationManifestPath( return getOperationManifestUrl( graphId, storageSecret, + 'current', ).replace(new RegExp(`^${urlOperationManifestBase}`), ''); } diff --git a/packages/apollo-server-plugin-operation-registry/src/agent.ts b/packages/apollo-server-plugin-operation-registry/src/agent.ts index 199a7a10424..98c70df4045 100644 --- a/packages/apollo-server-plugin-operation-registry/src/agent.ts +++ b/packages/apollo-server-plugin-operation-registry/src/agent.ts @@ -11,7 +11,7 @@ import { HttpRequestCache } from './cache'; import { InMemoryLRUCache } from 'apollo-server-caching'; import { OperationManifest } from "./ApolloServerPluginOperationRegistry"; -import { Logger } from "apollo-server-types"; +import { Logger, ApolloConfig, WithRequired } from "apollo-server-types"; import { Response, RequestInit, fetch } from "apollo-server-env"; const DEFAULT_POLL_SECONDS: number = 30; @@ -21,9 +21,8 @@ export interface AgentOptions { logger?: Logger; fetcher?: typeof fetch; pollSeconds?: number; - engine: any; + apollo: WithRequired; store: InMemoryLRUCache; - graphVariant: string; } type SignatureStore = Set; @@ -49,20 +48,6 @@ export default class Agent { this.logger = this.options.logger || loglevel.getLogger(pluginName); this.fetcher = this.options.fetcher || getDefaultGcsFetcher(); - - if ( - typeof this.options.engine !== 'object' || - typeof this.options.engine.serviceID !== 'string' - ) { - throw new Error('`engine.serviceID` must be passed to the Agent.'); - } - - if ( - typeof this.options.engine !== 'object' || - typeof this.options.engine.apiKeyHash !== 'string' - ) { - throw new Error('`engine.apiKeyHash` must be passed to the Agent.'); - } } async requestPending() { @@ -132,8 +117,8 @@ export default class Agent { private async fetchAndUpdateStorageSecret(): Promise { const storageSecretUrl = getStorageSecretUrl( - this.options.engine.serviceID, - this.options.engine.apiKeyHash, + this.options.apollo.graphId, + this.options.apollo.keyHash, ); const response = await this.fetcher(storageSecretUrl, this.fetchOptions); @@ -170,9 +155,9 @@ export default class Agent { } const storageSecretManifestUrl = getOperationManifestUrl( - this.options.engine.serviceID, + this.options.apollo.graphId, storageSecret, - this.options.graphVariant, + this.options.apollo.graphVariant, ); this.logger.debug( @@ -183,7 +168,7 @@ export default class Agent { if (response.status === 404 || response.status === 403) { throw new Error( - `No manifest found for tag "${this.options.graphVariant}" at ` + + `No manifest found for tag "${this.options.apollo.graphVariant}" at ` + `${storageSecretManifestUrl}. ${callToAction}`); } return response; @@ -218,7 +203,7 @@ export default class Agent { throw new Error(`Unexpected 'Content-Type' header: ${contentType}`); } } catch (err) { - const ourErrorPrefix = `Unable to fetch operation manifest for graph ID '${this.options.engine.serviceID}': ${err}`; + const ourErrorPrefix = `Unable to fetch operation manifest for graph ID '${this.options.apollo.graphId}': ${err}`; err.message = `${ourErrorPrefix}: ${err}`; diff --git a/packages/apollo-server-plugin-operation-registry/src/common.ts b/packages/apollo-server-plugin-operation-registry/src/common.ts index 16294d409e1..8aba17f2715 100644 --- a/packages/apollo-server-plugin-operation-registry/src/common.ts +++ b/packages/apollo-server-plugin-operation-registry/src/common.ts @@ -37,7 +37,7 @@ export function getStorageSecretUrl( export function getOperationManifestUrl( graphId: string, storageSecret: string, - graphVariant: string = 'current', + graphVariant: string, ): string { return `${urlOperationManifestBase}/${graphId}/${storageSecret}/${graphVariant}/manifest.v2.json`; } diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index 3d02d315fd1..3b277b519a1 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -51,17 +51,36 @@ type Mutable = { -readonly [P in keyof T]: T[P] }; export type SchemaHash = Fauxpaque; -export interface GraphQLServiceContext { +// Configuration for how Apollo Server talks to the Apollo registry, as +// passed to the ApolloServer constructor. +export interface ApolloConfigInput { + key?: string; + graphId?: string; + graphVariant?: string; +} + +// Configuration for how Apollo Server talks to the Apollo registry, with +// some defaults filled in from the ApolloConfigInput passed to the constructor. +export interface ApolloConfig { + key?: string; + keyHash?: string; + graphId?: string; + graphVariant: string; +} + + export interface GraphQLServiceContext { logger: Logger; schema: GraphQLSchema; schemaHash: SchemaHash; + apollo: ApolloConfig; + persistedQueries?: { + cache: KeyValueCache; + }; + // For backwards compatibility only; prefer to use `apollo`. engine: { serviceID?: string; apiKeyHash?: string; }; - persistedQueries?: { - cache: KeyValueCache; - }; } export interface GraphQLRequest {