@graphql-tools/federation
Version:
Useful tools to create and manipulate GraphQL schemas.
245 lines (244 loc) • 9.84 kB
JavaScript
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();
});
}
}