UNPKG

@graphql-tools/federation

Version:

Useful tools to create and manipulate GraphQL schemas.

245 lines (244 loc) • 9.84 kB
import { fetch as defaultFetch } from '@whatwg-node/fetch'; import { getStitchedSchemaFromSupergraphSdl, } from './supergraph.js'; import { EventEmitter } from './utils.js'; /** * The default managed federation up links. In case of failure, you should try to cycle through these up links. * * The first one is Apollo's managed federation up link on GCP, the second one is on AWS. */ export const DEFAULT_UPLINKS = [ 'https://uplink.api.apollographql.com/', 'https://aws.uplink.api.apollographql.com/', ]; /** * Fetches the supergraph SDL from a managed federation GraphOS up link. * @param options * @throws When the fetch fails or the response is not a valid. * @returns An object with the supergraph SDL when possible. It also includes metadata to handle polling and retry logic. * * If `lastSeenId` is provided and the supergraph has not changed, `supergraphSdl` is not present. * * If The up link report a fetch error (which is not a local fetch error), it will be returned along with polling/retry metadata. * Any local fetch error will be thrown as an exception. */ export async function fetchSupergraphSdlFromManagedFederation(options = {}) { const userDefinedUplinks = process.env['APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT']?.split(',') ?? []; const { upLink = userDefinedUplinks[0] || DEFAULT_UPLINKS[0], loggerByMessageLevel = DEFAULT_MESSAGE_LOGGER, fetch = defaultFetch, ...variables } = options; if (!variables.graphRef) { variables.graphRef = process.env['APOLLO_GRAPH_REF']; } if (!variables.apiKey) { variables.apiKey = process.env['APOLLO_KEY']; } const response = await fetch(upLink, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query: /* GraphQL */ ` query ($apiKey: String!, $graphRef: String!, $lastSeenId: ID) { routerConfig(ref: $graphRef, apiKey: $apiKey, ifAfterId: $lastSeenId) { __typename ... on FetchError { code message minDelaySeconds } ... on Unchanged { id minDelaySeconds } ... on RouterConfigResult { id supergraphSdl: supergraphSDL minDelaySeconds messages { level body } } } } `, variables, }), }); const responseBody = await response.text(); if (!response.ok) { throw new Error(`Failed to fetch supergraph SDL from managed federation up link '${upLink}': [${response.status} ${response.statusText}] ${responseBody}`); } let result; try { result = JSON.parse(responseBody); } catch (err) { throw new Error(`Failed to parse response from managed federation up link '${upLink}': ${err.message}\n\n${responseBody}`); } if (result.errors) { const errors = result.errors.map(({ message }) => '\n' + message).join(''); throw new Error(`Failed to fetch supergraph SDL from managed federation up link '${upLink}': ${errors}`); } if (!result.data?.routerConfig) { throw new Error(`Failed to fetch supergraph SDL from managed federation up link '${upLink}': ${responseBody}`); } const { routerConfig } = result.data; if (routerConfig.__typename === 'FetchError') { return { error: { code: routerConfig.code, message: routerConfig.message }, minDelaySeconds: routerConfig.minDelaySeconds, }; } if (routerConfig.__typename === 'Unchanged') { return { id: routerConfig.id, minDelaySeconds: routerConfig.minDelaySeconds }; } for (const message of routerConfig.messages) { loggerByMessageLevel[message.level](message.body); } return { supergraphSdl: routerConfig.supergraphSdl, id: routerConfig.id, minDelaySeconds: routerConfig.minDelaySeconds, }; } /** * Fetches the supergraph SDL from a managed federation GraphOS up link and stitches it into an executable schema. * @param options * @throws When the fetch fails, the response is not a valid or the stitching fails. * @returns An object with the supergraph SDL and the stitched schema when possible. It also includes metadata to handle polling and retry logic. * * If `lastSeenId` is provided and the supergraph has not changed, `supergraphSdl` is not present. * * If The up link report a fetch error (which is not a local fetch error), it will be returned along with polling/retry metadata. * Any local fetch error will be thrown as an exception. */ export async function getStitchedSchemaFromManagedFederation(options) { const result = await fetchSupergraphSdlFromManagedFederation({ graphRef: options.graphRef, apiKey: options.apiKey, upLink: options.upLink, lastSeenId: options.lastSeenId, fetch: options.fetch, loggerByMessageLevel: options.loggerByMessageLevel, }); if ('supergraphSdl' in result) { return { ...result, schema: getStitchedSchemaFromSupergraphSdl({ supergraphSdl: result.supergraphSdl, onStitchingOptions: options.onStitchingOptions, httpExecutorOpts: options.httpExecutorOpts, onSubschemaConfig: options.onSubschemaConfig, batch: options.batch, }), }; } return result; } const DEFAULT_MESSAGE_LOGGER = { ERROR: (message) => console.error('[Managed Federation] Uplink message: [ERROR]', message), WARN: (message) => console.warn('[Managed Federation] Uplink message: [WARN]', message), INFO: (message) => console.info('[Managed Federation] Uplink message: [INFO]', message), }; export class SupergraphSchemaManager extends EventEmitter { options; schema = undefined; #lastSeenId; #retries = 1; #timeout; constructor(options) { super(); this.options = options; registerCleanup(() => { this.stop(); }); } start = (delayInSeconds = 0) => { if (this.#timeout) { this.stop(); } this.#timeout = setTimeout(() => { this.#log('info', 'Polling started'); this.#retries = 1; this.#fetchSchema(); }, delayInSeconds * 1000); }; forcePull = () => { if (this.#timeout) { clearTimeout(this.#timeout); this.#timeout = undefined; } this.#retries = 1; this.#fetchSchema(); }; stop = () => { this.#log('info', 'Polling stopped'); if (this.#timeout) { clearTimeout(this.#timeout); this.#timeout = undefined; } }; #fetchSchema = async () => { const { retryDelaySeconds = 0, minDelaySeconds = 0 } = this.options; try { this.#log('info', 'Fetch schema from managed federation'); const result = await getStitchedSchemaFromManagedFederation({ ...this.options, loggerByMessageLevel: { ERROR: message => this.emit('log', { source: 'uplink', level: 'error', message }), WARN: message => this.emit('log', { source: 'uplink', level: 'warn', message }), INFO: message => this.emit('log', { source: 'uplink', level: 'info', message }), }, lastSeenId: this.#lastSeenId, }); if ('error' in result) { this.#lastSeenId = undefined; // When an error is reported, Apollo doesn't provide an id. this.emit('error', result.error); this.#retryOnError(result.error, Math.max(result.minDelaySeconds, minDelaySeconds)); return; } if ('schema' in result) { this.#lastSeenId = result.id; this.schema = result.schema; this.emit('schema', result.schema, result.supergraphSdl); this.#log('info', 'Supergraph successfully updated'); } else { this.#log('info', 'Supergraph is up to date'); } this.#retries = 1; const delay = Math.max(result.minDelaySeconds, minDelaySeconds); this.#timeout = setTimeout(this.#fetchSchema, delay * 1000); this.#log('info', `Next pull in ${delay.toFixed(1)} seconds`); } catch (e) { this.#retryOnError(e, retryDelaySeconds ?? 0); this.emit('error', e); } }; #retryOnError = (error, delayInSeconds) => { const { maxRetries = 3 } = this.options; const message = error?.message; this.#log('error', `Failed to pull schema from managed federation: ${message}`); if (this.#retries >= maxRetries) { this.#timeout = undefined; this.#log('error', 'Max retries reached, giving up'); this.emit('failure', error, delayInSeconds); return; } this.#retries++; this.#log('info', `Retrying (${this.#retries}/${maxRetries})${delayInSeconds ? ` in ${delayInSeconds.toFixed(1)} seconds` : ''}`); this.#timeout = setTimeout(this.#fetchSchema, delayInSeconds * 1000); }; #log = (level, message) => { this.emit('log', { source: 'manager', level, message }); }; } function registerCleanup(cleanupFn) { if (typeof global.process === 'object') { for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) process.on(signal, () => { cleanupFn(); }); } }