@graphql-hive/core
Version:
227 lines (226 loc) • 10.3 kB
JavaScript
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();
},
};
}