UNPKG

@graphql-hive/core

Version:
227 lines (226 loc) • 10.3 kB
import { readFile } from 'node:fs/promises'; import { resolve as resolvePath } from 'node:path'; import { buildClientSchema, getIntrospectionQuery, parse, printSchema, } from 'graphql'; import { composeServices, compositionHasErrors } from '@theguild/federation-composition'; import CircuitBreaker from '../circuit-breaker/circuit.js'; import { defaultCircuitBreakerConfiguration, } from './circuit-breaker.js'; import { http } from './http-client.js'; import { chooseLogger } from './utils.js'; export class LocalSupergraphCompositionError extends Error { constructor(compositionResult) { super('Local composition failed.'); this.compositionResult = compositionResult; } } /** The registry API returned a GraphQL/API-level error while composing remotely. */ export class SupergraphRegistryApiError extends Error { } /** Remote composition finished but produced composition errors. */ export class RemoteSupergraphCompositionError extends Error { constructor(errors) { super(`Remote composition failed:\n${errors.map(error => error.message).join('\n')}`); this.errors = errors; } } /** Remote composition reported success but did not return a usable supergraph SDL. */ export class InvalidSupergraphResultError extends Error { constructor(supergraphSdl) { super(`Remote composition resulted in an invalid supergraph: ${supergraphSdl}`); this.supergraphSdl = supergraphSdl; } } export async function composeSupergraphLocally(services) { const compositionResult = await new Promise((resolvePromise, reject) => { try { resolvePromise(composeServices(services.map(service => ({ name: service.name, url: service.url, typeDefs: parse(service.sdl), })))); } catch (error) { // composeServices should not throw; this reject covers the offchance that // something unexpected happens under the hood, so the promise doesn't hang. reject(error); } }); if (compositionHasErrors(compositionResult)) { throw new LocalSupergraphCompositionError(compositionResult); } return compositionResult.supergraphSdl; } export async function composeSupergraphRemotely(input) { var _a, _b; const response = await http.post(input.registry, JSON.stringify({ query: /* GraphQL */ ` mutation CreateDevFetcher_SchemaCompose($input: SchemaComposeInput!) { schemaCompose(input: $input) { __typename ... on SchemaComposeSuccess { valid compositionResult { supergraphSdl errors { edges { node { message } } } } } ... on SchemaComposeError { message } } } `, variables: { input: { useLatestComposableVersion: !input.unstable__forceLatest, services: input.services.map(service => ({ name: service.name, url: service.url, sdl: service.sdl, })), target: input.target, }, }, }), { headers: { 'content-type': 'application/json', authorization: `Bearer ${input.token}`, 'graphql-client-name': 'Hive Dev Fetcher', 'graphql-client-version': input.version, }, logger: input.logger, fetchImplementation: input.fetch, }); const body = await response.json(); if ((_a = body.errors) === null || _a === void 0 ? void 0 : _a.length) { throw new SupergraphRegistryApiError(body.errors.map(error => error.message).join(', ')); } const schemaCompose = (_b = body.data) === null || _b === void 0 ? void 0 : _b.schemaCompose; if (!schemaCompose) { throw new SupergraphRegistryApiError('Received an unexpected response from the registry.'); } if (schemaCompose.__typename === 'SchemaComposeError') { throw new SupergraphRegistryApiError(schemaCompose.message); } const { valid, compositionResult } = schemaCompose; if (!valid) { if (compositionResult.errors) { throw new RemoteSupergraphCompositionError(compositionResult.errors.edges.map(edge => edge.node)); } throw new InvalidSupergraphResultError(compositionResult.supergraphSdl); } if (typeof compositionResult.supergraphSdl !== 'string') { throw new InvalidSupergraphResultError(compositionResult.supergraphSdl); } return compositionResult.supergraphSdl; } async function introspectFederationService(service, logger, fetch) { var _a, _b, _c; const response = await http.post(service.url, JSON.stringify({ query: '{ _service { sdl } }' }), { headers: { 'content-type': 'application/json' }, logger, fetchImplementation: fetch, }); const body = await response.json(); if (((_a = body.errors) === null || _a === void 0 ? void 0 : _a.length) || !((_c = (_b = body.data) === null || _b === void 0 ? void 0 : _b._service) === null || _c === void 0 ? void 0 : _c.sdl)) { throw new Error(`Could not get a federation introspection result from the service "${service.name}". ` + `Make sure the service exposes a federation "_service { sdl }" field, or set its ` + `"introspection" option to "graphql" to use standard GraphQL introspection instead.`); } return body.data._service.sdl; } async function introspectGraphQLService(service, logger, fetch) { var _a; const response = await http.post(service.url, JSON.stringify({ query: getIntrospectionQuery() }), { headers: { 'content-type': 'application/json' }, logger, fetchImplementation: fetch, }); const body = await response.json(); if (((_a = body.errors) === null || _a === void 0 ? void 0 : _a.length) || !body.data) { throw new Error(`Could not get introspection result from the service "${service.name}". Make sure introspection is enabled by the server.`); } return printSchema(buildClientSchema(body.data)); } async function resolveService(service, cwd, logger, fetch) { if (service.source === 'file') { const filePath = resolvePath(cwd, service.schema); const contents = await readFile(filePath, 'utf8'); // `parse` here only validates the file's contents; `contents` is kept as-is rather than // reprinting it, since it's re-parsed anyway by whichever composition path consumes it. parse(contents); return { name: service.name, url: service.url, sdl: contents }; } const sdl = service.source === 'graphql' ? await introspectGraphQLService(service, logger, fetch) : await introspectFederationService(service, logger, fetch); return { name: service.name, url: service.url, sdl }; } async function resolveServices(services, cwd, logger, fetch) { return Promise.all(services.map(service => resolveService(service, cwd, logger, fetch))); } const CACHE_KEY = 'hive:dev-fetcher:supergraph'; function servicesUnchanged(previous, next) { if (previous.length !== next.length) { return false; } return next.every(service => { var _a; return ((_a = previous.find(p => p.name === service.name)) === null || _a === void 0 ? void 0 : _a.sdl) === service.sdl; }); } /** * Create a fetcher that can get subgraph definitions from a local file, graphql introspection, * or federated introspection (default), and then compose these services with the latest schema * stored in Hive with these subgraphs replaced (based on service name). * * This is an alternative to using `@graphql-hive/cli`'s dev command. * * The composed supergraph is cached and is only recomposed if the provided service SDLs change. But * introspection and file reading is ran on every call, so if using Hive Gateway's polling interval, * set the interval accordingly. Composition is also CircuitBreaked, so that the expensive composition * request is guaranteed not to run too frequently. */ export function createDevFetcher(options) { var _a, _b; const logger = chooseLogger(options.logger); const cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd(); const circuitBreakerConfig = (_b = options.circuitBreaker) !== null && _b !== void 0 ? _b : defaultCircuitBreakerConfiguration; const composeBreaker = new CircuitBreaker(async (services) => { var _a, _b, _c; if (options.remote) { if (!options.registry || !options.token) { throw new Error('`registry` and `token` are required when `remote` is enabled.'); } return await composeSupergraphRemotely({ services, registry: options.registry, token: options.token, unstable__forceLatest: (_a = options.unstable__forceLatest) !== null && _a !== void 0 ? _a : false, target: (_b = options.target) !== null && _b !== void 0 ? _b : null, version: (_c = options.version) !== null && _c !== void 0 ? _c : 'unknown', logger, fetch: options.fetch, }); } return await composeSupergraphLocally(services); }, Object.assign(Object.assign({}, circuitBreakerConfig), { timeout: false })); return { async fetch() { var _a, _b; const services = await resolveServices(options.services, cwd, logger, options.fetch); const cached = await ((_a = options.cache) === null || _a === void 0 ? void 0 : _a.get(CACHE_KEY)); if (cached && servicesUnchanged(cached.services, services)) { return cached.supergraphSdl; } const supergraphSdl = await composeBreaker.fire(services); await ((_b = options.cache) === null || _b === void 0 ? void 0 : _b.set(CACHE_KEY, { services, supergraphSdl })); return supergraphSdl; }, dispose() { composeBreaker.shutdown(); }, }; }