diff --git a/gateway-js/CHANGELOG.md b/gateway-js/CHANGELOG.md index e0299352a4..3b50ef1363 100644 --- a/gateway-js/CHANGELOG.md +++ b/gateway-js/CHANGELOG.md @@ -5,11 +5,12 @@ > 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. - __NOOP__: Fix OOB testing w.r.t. nock hygiene. Pushed error reporting endpoint responsibilities up into the gateway class, but there should be no effect on the runtime at all. [PR #1309](https://github.com/apollographql/federation/pull/1309) +- __BREAKING__: Remove legacy GCS fetcher for schema updates. If you're currently opted-in to the backwards compatibility provided by setting `schemaConfigDeliveryEndpoint: null`, you may be affected by this update. Please see the PR for additional details. [PR #1225](https://github.com/apollographql/federation/pull/1225) ## v0.44.0 - __BREAKING__: Update `@apollo/core-schema` usage and `graphql` peerDependencies. The core schema package suffered from incompatible changes in the latest graphql versions (^15.7.0). The core schema has since been updated. This updates our usage to the latest version, but in doing so requires us to update our peerDependency requirement of graphql-js to the latest v15 release (15.7.2) [PR #1140](https://github.com/apollographql/federation/pull/1140) -- Conditional schema update based on ifAfterId [PR #TODO](https://github.com/apollographql/federation/pull/TODO) +- Conditional schema update based on `ifAfterId` [PR #1198](https://github.com/apollographql/federation/pull/1198) ## v0.43.0 diff --git a/gateway-js/src/__tests__/integration/configuration.test.ts b/gateway-js/src/__tests__/integration/configuration.test.ts index 53c73a8f34..a815948433 100644 --- a/gateway-js/src/__tests__/integration/configuration.test.ts +++ b/gateway-js/src/__tests__/integration/configuration.test.ts @@ -110,7 +110,6 @@ describe('gateway configuration warnings', () => { gateway = new ApolloGateway({ logger, - // TODO(trevor:cloudconfig): remove schemaConfigDeliveryEndpoint: mockCloudConfigUrl, }); @@ -300,7 +299,6 @@ describe('gateway config / env behavior', () => { }); }); - // TODO(trevor:cloudconfig): this behavior will be updated describe('schema config delivery endpoint configuration', () => { it('A code config overrides the env variable', async () => { cleanUp = mockedEnv({ @@ -318,22 +316,5 @@ describe('gateway config / env behavior', () => { gateway = null; }); - - it('A code config set to `null` takes precedence over an existing env variable', async () => { - cleanUp = mockedEnv({ - APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT: 'env-config', - }); - - gateway = new ApolloGateway({ - logger, - schemaConfigDeliveryEndpoint: null, - }); - - expect(gateway['schemaConfigDeliveryEndpoint']).toEqual( - null, - ); - - gateway = null; - }); }); }); diff --git a/gateway-js/src/__tests__/integration/legacyNetworkRequests.test.ts b/gateway-js/src/__tests__/integration/legacyNetworkRequests.test.ts deleted file mode 100644 index 5698968f66..0000000000 --- a/gateway-js/src/__tests__/integration/legacyNetworkRequests.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import nock from 'nock'; -import { fetch } from 'apollo-server-env'; -import { Logger } from 'apollo-server-types'; -import { ApolloGateway, GCS_RETRY_COUNT, getDefaultFetcher } from '../..'; -import { - mockServiceHealthCheckSuccess, - mockServiceHealthCheck, - mockStorageSecretSuccess, - mockStorageSecret, - mockCompositionConfigLinkSuccess, - mockCompositionConfigLink, - mockCompositionConfigsSuccess, - mockCompositionConfigs, - mockImplementingServicesSuccess, - mockImplementingServices, - mockRawPartialSchemaSuccess, - mockRawPartialSchema, - apiKeyHash, - graphId, -} from './legacyNockMocks'; - -export interface MockService { - gcsDefinitionPath: string; - partialSchemaPath: string; - url: string; - sdl: string; -} - -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 - } - `, -}; - -const updatedService: MockService = { - gcsDefinitionPath: 'updated-service-definition.json', - partialSchemaPath: 'updated-accounts-partial-schema.json', - url: 'http://localhost:4002', - sdl: `#graphql - extend type Query { - me: User - everyone: [User] - } - "This is my updated User" - type User @key(fields: "id") { - id: ID! - name: String - username: String - } - `, -}; - -let fetcher: typeof fetch; -let logger: Logger; -let gateway: ApolloGateway | null = null; - -beforeEach(() => { - if (!nock.isActive()) nock.activate(); - - fetcher = getDefaultFetcher().defaults({ - retry: { - retries: GCS_RETRY_COUNT, - minTimeout: 0, - maxTimeout: 0, - }, - }); - - const warn = jest.fn(); - const debug = jest.fn(); - const error = jest.fn(); - const info = jest.fn(); - - logger = { - warn, - debug, - error, - info, - }; -}); - -afterEach(async () => { - expect(nock.isDone()).toBeTruthy(); - nock.cleanAll(); - nock.restore(); - if (gateway) { - await gateway.stop(); - gateway = null; - } -}); - -it('Extracts service definitions from remote storage', async () => { - mockStorageSecretSuccess(); - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([service]); - mockImplementingServicesSuccess(service); - mockRawPartialSchemaSuccess(service); - - gateway = new ApolloGateway({ logger, schemaConfigDeliveryEndpoint: null }); - - await gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }); - expect(gateway.schema!.getType('User')!.description).toBe('This is my User'); -}); - -function failNTimes(n: number, fn: () => nock.Interceptor) { - for (let i = 0; i < n; i++) { - fn().reply(500); - } -} - -it(`Retries GCS (up to ${GCS_RETRY_COUNT} times) on failure for each request and succeeds`, async () => { - failNTimes(GCS_RETRY_COUNT, mockStorageSecret); - mockStorageSecretSuccess(); - - failNTimes(GCS_RETRY_COUNT, mockCompositionConfigLink); - mockCompositionConfigLinkSuccess(); - - failNTimes(GCS_RETRY_COUNT, mockCompositionConfigs); - mockCompositionConfigsSuccess([service]); - - failNTimes(GCS_RETRY_COUNT, () => mockImplementingServices(service)); - mockImplementingServicesSuccess(service); - - failNTimes(GCS_RETRY_COUNT, () => mockRawPartialSchema(service)); - mockRawPartialSchemaSuccess(service); - - gateway = new ApolloGateway({ - fetcher, - logger, - schemaConfigDeliveryEndpoint: null, - }); - - await gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }); - expect(gateway.schema!.getType('User')!.description).toBe('This is my User'); -}); - -describe('Managed mode', () => { - it('Performs health checks to downstream services on load', async () => { - mockStorageSecretSuccess(); - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([service]); - mockImplementingServicesSuccess(service); - mockRawPartialSchemaSuccess(service); - - mockServiceHealthCheckSuccess(service); - - gateway = new ApolloGateway({ - serviceHealthCheck: true, - logger, - schemaConfigDeliveryEndpoint: null, - }); - - await gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }); - expect(gateway.schema!.getType('User')!.description).toBe( - 'This is my User', - ); - }); - - it('Rejects on initial load when health check fails', async () => { - mockStorageSecretSuccess(); - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([service]); - mockImplementingServicesSuccess(service); - mockRawPartialSchemaSuccess(service); - - mockServiceHealthCheck(service).reply(500); - - const gateway = new ApolloGateway({ - serviceHealthCheck: true, - logger, - schemaConfigDeliveryEndpoint: null, - }); - - await expect( - gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }), - ).rejects.toThrowErrorMatchingInlineSnapshot(` - "The gateway did not update its schema due to failed service health checks. The gateway will continue to operate with the previous schema and reattempt updates. The following error occurred during the health check: - [accounts]: 500: Internal Server Error" - `); - }); - - it('Preserves original schema when health check fails', async () => { - mockStorageSecretSuccess(); - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([service]); - mockImplementingServicesSuccess(service); - mockRawPartialSchemaSuccess(service); - mockServiceHealthCheckSuccess(service); - - // Update - mockStorageSecretSuccess(); - mockCompositionConfigLinkSuccess(); - mockCompositionConfigsSuccess([updatedService]); - mockImplementingServicesSuccess(updatedService); - mockRawPartialSchemaSuccess(updatedService); - mockServiceHealthCheck(updatedService).reply(500); - - let resolve: () => void; - const schemaChangeBlocker = new Promise((res) => (resolve = res)); - - gateway = new ApolloGateway({ - serviceHealthCheck: true, - logger, - schemaConfigDeliveryEndpoint: null, - }); - // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here - gateway.experimental_pollInterval = 100; - - // @ts-ignore for testing purposes, we'll call the original `updateSchema` - // function from our mock. The first call should mimic original behavior, - // but the second call needs to handle the PromiseRejection. Typically for tests - // like these we would leverage the `gateway.onSchemaChange` callback to drive - // the test, but in this case, that callback isn't triggered when the update - // fails (as expected) so we get creative with the second mock as seen below. - const original = gateway.updateSchema; - const mockUpdateSchema = jest - .fn() - .mockImplementationOnce(async () => { - await original.apply(gateway); - }) - .mockImplementationOnce(async () => { - // mock the first poll and handle the error which would otherwise be caught - // and logged from within the `pollServices` class method - try { - await original.apply(gateway); - } catch (e) { - var err = e; - } - - expect(err.message).toMatchInlineSnapshot(` - "The gateway did not update its schema due to failed service health checks. The gateway will continue to operate with the previous schema and reattempt updates. The following error occurred during the health check: - [accounts]: 500: Internal Server Error" - `); - // finally resolve the promise which drives this test - resolve(); - }); - - // @ts-ignore for testing purposes, replace the `updateSchema` - // function on the gateway with our mock - gateway.updateSchema = mockUpdateSchema; - - // load the gateway as usual - await gateway.load({ - apollo: { keyHash: apiKeyHash, graphId, graphVariant: 'current' }, - }); - - expect(gateway.schema!.getType('User')!.description).toBe( - 'This is my User', - ); - - await schemaChangeBlocker; - - // At this point, the mock update should have been called but the schema - // should not have updated to the new one. - expect(mockUpdateSchema.mock.calls.length).toBe(2); - expect(gateway.schema!.getType('User')!.description).toBe( - 'This is my User', - ); - }); -}); diff --git a/gateway-js/src/__tests__/integration/legacyNockMocks.ts b/gateway-js/src/__tests__/integration/legacyNockMocks.ts deleted file mode 100644 index 396435d3b3..0000000000 --- a/gateway-js/src/__tests__/integration/legacyNockMocks.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { HEALTH_CHECK_QUERY, SERVICE_DEFINITION_QUERY } from '@apollo/gateway'; -import nock from 'nock'; -import { MockService } from './legacyNetworkRequests.test'; - -export const graphId = 'federated-service'; -export const apiKeyHash = 'dd55a79d467976346d229a7b12b673ce'; -const storageSecret = 'my-storage-secret'; -const accountsService = 'accounts'; - -// Service mocks -function mockSdlQuery({ url }: MockService) { - return nock(url).post('/', { - query: SERVICE_DEFINITION_QUERY, - }); -} - -export function mockSdlQuerySuccess(service: MockService) { - mockSdlQuery(service).reply(200, { - data: { _service: { sdl: service.sdl } }, - }); -} - -export function mockServiceHealthCheck({ url }: MockService) { - return nock(url).post('/', { - query: HEALTH_CHECK_QUERY, - }); -} - -export function mockServiceHealthCheckSuccess(service: MockService) { - return mockServiceHealthCheck(service).reply(200, { - data: { __typename: 'Query' }, - }); -} - -// GCS mocks -function gcsNock(url: Parameters[0]): nock.Scope { - const { name, version } = require('../../../package.json'); - return nock(url, { - reqheaders: { - 'apollographql-client-name': name, - 'apollographql-client-version': version, - 'user-agent': `${name}/${version}`, - 'content-type': 'application/json', - }, - }); -} - -export function mockStorageSecret() { - return gcsNock('https://storage-secrets.api.apollographql.com:443').get( - `/${graphId}/storage-secret/${apiKeyHash}.json`, - ); -} - -export function mockStorageSecretSuccess() { - return gcsNock('https://storage-secrets.api.apollographql.com:443') - .get(`/${graphId}/storage-secret/${apiKeyHash}.json`) - .reply(200, `"${storageSecret}"`); -} - -// get composition config link, using received storage secret -export function mockCompositionConfigLink() { - return gcsNock('https://federation.api.apollographql.com:443').get( - `/${storageSecret}/current/v1/composition-config-link`, - ); -} - -export function mockCompositionConfigLinkSuccess() { - return mockCompositionConfigLink().reply(200, { - configPath: `${storageSecret}/current/v1/composition-configs/composition-config-path.json`, - }); -} - -// get composition configs, using received composition config link -export function mockCompositionConfigs() { - return gcsNock('https://federation.api.apollographql.com:443').get( - `/${storageSecret}/current/v1/composition-configs/composition-config-path.json`, - ); -} - -export function mockCompositionConfigsSuccess(services: MockService[]) { - return mockCompositionConfigs().reply(200, { - implementingServiceLocations: services.map((service) => ({ - name: accountsService, - path: `${storageSecret}/current/v1/implementing-services/${accountsService}/${service.gcsDefinitionPath}`, - })), - }); -} - -// get implementing service reference, using received composition-config -export function mockImplementingServices({ gcsDefinitionPath }: MockService) { - return gcsNock('https://federation.api.apollographql.com:443').get( - `/${storageSecret}/current/v1/implementing-services/${accountsService}/${gcsDefinitionPath}`, - ); -} - -export function mockImplementingServicesSuccess(service: MockService) { - return mockImplementingServices(service).reply(200, { - name: accountsService, - partialSchemaPath: `${storageSecret}/current/raw-partial-schemas/${service.partialSchemaPath}`, - url: service.url, - }); -} - -// get raw-partial-schema, using received composition-config -export function mockRawPartialSchema({ partialSchemaPath }: MockService) { - return gcsNock('https://federation.api.apollographql.com:443').get( - `/${storageSecret}/current/raw-partial-schemas/${partialSchemaPath}`, - ); -} - -export function mockRawPartialSchemaSuccess(service: MockService) { - return mockRawPartialSchema(service).reply(200, service.sdl); -} diff --git a/gateway-js/src/__tests__/integration/networkRequests.test.ts b/gateway-js/src/__tests__/integration/networkRequests.test.ts index 47eba8906e..c5a56d7fab 100644 --- a/gateway-js/src/__tests__/integration/networkRequests.test.ts +++ b/gateway-js/src/__tests__/integration/networkRequests.test.ts @@ -99,7 +99,6 @@ it('Queries remote endpoints for their SDLs', async () => { expect(gateway.schema!.getType('User')!.description).toBe('This is my User'); }); -// TODO(trevor:cloudconfig): Remove all usages of the experimental config option it('Fetches Supergraph SDL from remote storage', async () => { mockSupergraphSdlRequestSuccess(); @@ -113,7 +112,6 @@ it('Fetches Supergraph SDL from remote storage', async () => { expect(gateway.schema?.getType('User')).toBeTruthy(); }); -// TODO(trevor:cloudconfig): This test should evolve to demonstrate overriding the default in the future it('Fetches Supergraph SDL from remote storage using a configured env variable', async () => { cleanUp = mockedEnv({ APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT: mockCloudConfigUrl, @@ -154,7 +152,7 @@ it('Updates Supergraph SDL from remote storage', async () => { }); // @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here gateway.experimental_pollInterval = 100; - gateway.onSchemaChange(schemaChangeCallback); + gateway.onSchemaLoadOrUpdate(schemaChangeCallback); await gateway.load(mockApolloConfig); expect(gateway['compositionId']).toMatchInlineSnapshot(`"originalId-1234"`); diff --git a/gateway-js/src/config.ts b/gateway-js/src/config.ts index 64ec1eadb6..14cc593c4f 100644 --- a/gateway-js/src/config.ts +++ b/gateway-js/src/config.ts @@ -135,31 +135,14 @@ export interface RemoteGatewayConfig extends GatewayConfigBase { | ((service: ServiceEndpointDefinition) => Promise | HeadersInit); } -// TODO(trevor:cloudconfig): This type goes away -export interface LegacyManagedGatewayConfig extends GatewayConfigBase { - federationVersion?: number; - /** - * Setting this to null will cause the gateway to use the old mechanism for - * managed federation via GCS + composition. - */ - schemaConfigDeliveryEndpoint: null; -} - -// TODO(trevor:cloudconfig): This type becomes the only managed config -export interface PrecomposedManagedGatewayConfig extends GatewayConfigBase { +export interface ManagedGatewayConfig extends GatewayConfigBase { /** * This configuration option shouldn't be used unless by recommendation from - * Apollo staff. This can also be set to `null` (see above) in order to revert - * to the previous mechanism for managed federation. + * Apollo staff. */ schemaConfigDeliveryEndpoint?: string; } -// TODO(trevor:cloudconfig): This union is no longer needed -export type ManagedGatewayConfig = - | LegacyManagedGatewayConfig - | PrecomposedManagedGatewayConfig; - interface ManuallyManagedServiceDefsGatewayConfig extends GatewayConfigBase { experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions; } @@ -216,30 +199,12 @@ export function isManuallyManagedConfig( export function isManagedConfig( config: GatewayConfig, ): config is ManagedGatewayConfig { - return isPrecomposedManagedConfig(config) || isLegacyManagedConfig(config); -} - -// TODO(trevor:cloudconfig): This merges with `isManagedConfig` -export function isPrecomposedManagedConfig( - config: GatewayConfig, -): config is PrecomposedManagedGatewayConfig { - return ( - !isLegacyManagedConfig(config) && - (('schemaConfigDeliveryEndpoint' in config && - typeof config.schemaConfigDeliveryEndpoint === 'string') || - (!isRemoteConfig(config) && - !isLocalConfig(config) && - !isSupergraphSdlConfig(config) && - !isManuallyManagedConfig(config))) - ); -} - -export function isLegacyManagedConfig( - config: GatewayConfig, -): config is LegacyManagedGatewayConfig { return ( - 'schemaConfigDeliveryEndpoint' in config && - config.schemaConfigDeliveryEndpoint === null + 'schemaConfigDeliveryEndpoint' in config || + (!isRemoteConfig(config) && + !isLocalConfig(config) && + !isSupergraphSdlConfig(config) && + !isManuallyManagedConfig(config)) ); } diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index e129766106..86040759b2 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -66,11 +66,8 @@ import { ServiceDefinitionUpdate, SupergraphSdlUpdate, CompositionUpdate, - isPrecomposedManagedConfig, - isLegacyManagedConfig, } from './config'; import { loadSupergraphSdlFromStorage } from './loadSupergraphSdlFromStorage'; -import { getServiceDefinitionsFromStorage } from './legacyLoadServicesFromStorage'; import { buildComposedSchema } from '@apollo/query-planner'; import { SpanStatusCode } from '@opentelemetry/api'; import { OpenTelemetrySpanNames, tracer } from './utilities/opentelemetry'; @@ -114,20 +111,6 @@ export function getDefaultFetcher() { }); } -/** - * TODO(trevor:cloudconfig): Stop exporting this - * @deprecated This will be removed in a future version of @apollo/gateway - */ -export const getDefaultGcsFetcher = deprecate( - getDefaultFetcher, - `'getDefaultGcsFetcher' is deprecated. Use 'getDefaultFetcher' instead.`, -); -/** - * TODO(trevor:cloudconfig): Stop exporting this - * @deprecated This will be removed in a future version of @apollo/gateway - */ -export const GCS_RETRY_COUNT = 5; - export const HEALTH_CHECK_QUERY = 'query __ApolloServiceHealthCheck__ { __typename }'; export const SERVICE_DEFINITION_QUERY = @@ -219,10 +202,8 @@ export class ApolloGateway implements GraphQLService { private experimental_pollInterval?: number; // Configure the endpoint by which gateway will access its precomposed schema. // * `string` means use that endpoint - // * `null` will revert the gateway to legacy mode (polling GCS and composing the schema itself). // * `undefined` means the gateway is not using managed federation - // TODO(trevor:cloudconfig): `null` should be disallowed in the future. - private schemaConfigDeliveryEndpoint?: string | null; + private schemaConfigDeliveryEndpoint?: string; constructor(config?: GatewayConfig) { this.config = { @@ -250,19 +231,14 @@ export class ApolloGateway implements GraphQLService { this.experimental_pollInterval = config?.experimental_pollInterval; // 1. If config is set to a `string`, use it - // 2. If config is explicitly set to `null`, fallback to GCS - // 3. If the env var is set, use that - // 4. If config is `undefined`, use the default uplink URL - - // This if case unobviously handles 1, 2, and 4. - if (isPrecomposedManagedConfig(this.config)) { + // 2. If the env var is set, use that + // 3. If config is `undefined`, use the default uplink URL + if (isManagedConfig(this.config)) { const envEndpoint = process.env.APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT; this.schemaConfigDeliveryEndpoint = this.config.schemaConfigDeliveryEndpoint ?? envEndpoint ?? 'https://uplink.api.apollographql.com/'; - } else if (isLegacyManagedConfig(this.config)) { - this.schemaConfigDeliveryEndpoint = null; } if (isManuallyManagedConfig(this.config)) { @@ -982,31 +958,21 @@ export class ApolloGateway implements GraphQLService { ); } - // TODO(trevor:cloudconfig): This condition goes away completely - if (isPrecomposedManagedConfig(config)) { - const result = await loadSupergraphSdlFromStorage({ - graphRef: this.apolloConfig!.graphRef!, - apiKey: this.apolloConfig!.key!, - endpoint: this.schemaConfigDeliveryEndpoint!, - errorReportingEndpoint: this.errorReportingEndpoint, - fetcher: this.fetcher, - compositionId: this.compositionId ?? null, - }); + const result = await loadSupergraphSdlFromStorage({ + graphRef: this.apolloConfig!.graphRef!, + apiKey: this.apolloConfig!.key!, + endpoint: this.schemaConfigDeliveryEndpoint!, + errorReportingEndpoint: this.errorReportingEndpoint, + fetcher: this.fetcher, + compositionId: this.compositionId ?? null, + }); - return result ?? { + return ( + result ?? { id: this.compositionId!, supergraphSdl: this.supergraphSdl!, } - } else if (isLegacyManagedConfig(config)) { - return getServiceDefinitionsFromStorage({ - graphRef: this.apolloConfig!.graphRef!, - apiKeyHash: this.apolloConfig!.keyHash!, - federationVersion: config.federationVersion || 1, - fetcher: this.fetcher, - }); - } else { - throw new Error('Programming error: unhandled configuration'); - } + ); } private maybeWarnOnConflictingConfig() { diff --git a/gateway-js/src/legacyLoadServicesFromStorage.ts b/gateway-js/src/legacyLoadServicesFromStorage.ts deleted file mode 100644 index b1bbd13b5d..0000000000 --- a/gateway-js/src/legacyLoadServicesFromStorage.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { fetch } from 'apollo-server-env'; -import { parse } from 'graphql'; -import { ServiceDefinitionUpdate } from './config'; - -interface LinkFileResult { - configPath: string; - formatVersion: number; -} - -interface ImplementingService { - formatVersion: number; - graphID: string; - graphVariant: string; - name: string; - revision: string; - url: string; - partialSchemaPath: string; -} - -interface ImplementingServiceLocation { - name: string; - path: string; -} - -export interface CompositionMetadata { - formatVersion: number; - id: string; - implementingServiceLocations: ImplementingServiceLocation[]; - schemaHash: string; -} - -const envOverridePartialSchemaBaseUrl = 'APOLLO_PARTIAL_SCHEMA_BASE_URL'; -const envOverrideStorageSecretBaseUrl = 'APOLLO_STORAGE_SECRET_BASE_URL'; - -const urlFromEnvOrDefault = (envKey: string, fallback: string) => - (process.env[envKey] || fallback).replace(/\/$/, ''); - -// Generate and cache our desired operation manifest URL. -const urlPartialSchemaBase = urlFromEnvOrDefault( - envOverridePartialSchemaBaseUrl, - 'https://federation.api.apollographql.com/', -); - -const urlStorageSecretBase: string = urlFromEnvOrDefault( - envOverrideStorageSecretBaseUrl, - 'https://storage-secrets.api.apollographql.com/', -); - -function getStorageSecretUrl(graphId: string, apiKeyHash: string): string { - return `${urlStorageSecretBase}/${graphId}/storage-secret/${apiKeyHash}.json`; -} - -function fetchApolloGcs( - fetcher: typeof fetch, - ...args: Parameters -): ReturnType { - const [input, init] = args; - - // Used in logging. - const url = (typeof input === 'object' && input.url) || input; - - return fetcher(input, init) - .catch((fetchError) => { - throw new Error('Cannot access Apollo storage: ' + fetchError); - }) - .then(async (response) => { - // If the fetcher has a cache and has implemented ETag validation, then - // a 304 response may be returned. Either way, we will return the - // non-JSON-parsed version and let the caller decide if that's important - // to their needs. - if (response.ok || response.status === 304) { - return response; - } - - // We won't make any assumptions that the body is anything but text, to - // avoid parsing errors in this unknown condition. - const body = await response.text(); - - // Google Cloud Storage returns an `application/xml` error under error - // conditions. We'll special-case our known errors, and resort to - // printing the body for others. - if (response.status === 403 && body.includes('AccessDenied')) { - throw new Error( - 'Unable to authenticate with Apollo storage ' + - 'while fetching ' + - url + - '. Ensure that the API key is ' + - 'configured properly and that a federated service has been ' + - 'pushed. For details, see ' + - 'https://go.apollo.dev/g/resolve-access-denied.', - ); - } - - // Normally, we'll try to keep the logs clean with errors we expect. - // If it's not a known error, reveal the full body for debugging. - throw new Error('Could not communicate with Apollo storage: ' + body); - }); -} - -export async function getServiceDefinitionsFromStorage({ - graphRef, - apiKeyHash, - federationVersion, - fetcher, -}: { - graphRef: string; - apiKeyHash: string; - federationVersion: number; - fetcher: typeof fetch; -}): Promise { - // The protocol for talking to GCS requires us to split the graph ref - // into ID and variant; sigh. - const at = graphRef.indexOf('@'); - const graphId = at === -1 ? graphRef : graphRef.substring(0, at); - const graphVariant = at === -1 ? 'current' : graphRef.substring(at + 1); - - // fetch the storage secret - const storageSecretUrl = getStorageSecretUrl(graphId, apiKeyHash); - - // The storage secret is a JSON string (e.g. `"secret"`). - const secret: string = await fetchApolloGcs( - fetcher, - storageSecretUrl, - ).then((res) => res.json()); - - const baseUrl = `${urlPartialSchemaBase}/${secret}/${graphVariant}/v${federationVersion}`; - - const compositionConfigResponse = await fetchApolloGcs( - fetcher, - `${baseUrl}/composition-config-link`, - ); - - if (compositionConfigResponse.status === 304) { - return { isNewSchema: false }; - } - - const linkFileResult: LinkFileResult = await compositionConfigResponse.json(); - - const compositionMetadata: CompositionMetadata = await fetchApolloGcs( - fetcher, - `${urlPartialSchemaBase}/${linkFileResult.configPath}`, - ).then((res) => res.json()); - - // It's important to maintain the original order here - const serviceDefinitions = await Promise.all( - compositionMetadata.implementingServiceLocations.map( - async ({ name, path }) => { - const { url, partialSchemaPath }: ImplementingService = await fetcher( - `${urlPartialSchemaBase}/${path}`, - ).then((response) => response.json()); - - const sdl = await fetcher( - `${urlPartialSchemaBase}/${partialSchemaPath}`, - ).then((response) => response.text()); - - return { name, url, typeDefs: parse(sdl) }; - }, - ), - ); - - // explicity return that this is a new schema, as the link file has changed. - // we can't use the hit property of the fetchPartialSchemaFiles, as the partial - // schema may all be cache hits with the final schema still being new - // (for instance if a partial schema is removed or a partial schema is rolled back to a prior version, which is still in cache) - return { - serviceDefinitions, - compositionMetadata, - isNewSchema: true, - }; -}