UNPKG

@graphql-hive/cli

Version:

A CLI util to manage and control your GraphQL Hive

403 lines • 14.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const promises_1 = require("node:fs/promises"); const node_path_1 = require("node:path"); const graphql_1 = require("graphql"); const core_1 = require("@oclif/core"); const federation_composition_1 = require("@theguild/federation-composition"); const base_command_1 = tslib_1.__importDefault(require("../base-command")); const gql_1 = require("../gql"); const config_1 = require("../helpers/config"); const errors_1 = require("../helpers/errors"); const schema_1 = require("../helpers/schema"); const TargetInput = tslib_1.__importStar(require("../helpers/target-input")); const validation_1 = require("../helpers/validation"); const CLI_SchemaComposeMutation = (0, gql_1.graphql)(/* GraphQL */ ` mutation CLI_SchemaComposeMutation($input: SchemaComposeInput!) { schemaCompose(input: $input) { __typename ... on SchemaComposeSuccess { valid compositionResult { supergraphSdl errors { total nodes { message } } } } ... on SchemaComposeError { message } } } `); const ServiceIntrospectionQuery = /* GraphQL */ ` query ServiceSdlQuery { _service { sdl } } `; class Dev extends base_command_1.default { async run() { const { flags } = await this.parse(Dev); const { unstable__forceLatest } = flags; if (flags.service.length !== flags.url.length) { throw new errors_1.ServiceAndUrlLengthMismatch(flags.service, flags.url); } const isRemote = flags.remote === true; const serviceInputs = flags.service.map((name, i) => { const url = flags.url[i]; const sdl = flags.schema ? flags.schema[i] : undefined; return { name, url, sdl, }; }); let target = null; if (flags.target) { const result = TargetInput.parse(flags.target); if (result.type === 'error') { throw new errors_1.InvalidTargetError(); } target = result.data; } if (flags.watch === true) { if (isRemote) { let registry, token; try { registry = this.ensure({ key: 'registry.endpoint', legacyFlagName: 'registry', args: flags, defaultValue: config_1.graphqlEndpoint, env: 'HIVE_REGISTRY', description: Dev.flags['registry.endpoint'].description, }); } catch (e) { throw new errors_1.MissingEndpointError(); } try { token = this.ensure({ key: 'registry.accessToken', legacyFlagName: 'token', args: flags, env: 'HIVE_TOKEN', description: Dev.flags['registry.accessToken'].description, }); } catch (e) { throw new errors_1.MissingRegistryTokenError(); } void this.watch(flags.watchInterval, serviceInputs, services => this.compose({ services, registry, token, write: flags.write, unstable__forceLatest, target, onError: error => { // watch mode should not exit. Log instead. this.logFailure(error.message); }, })); return; } void this.watch(flags.watchInterval, serviceInputs, services => this.composeLocally({ services, write: flags.write, onError: error => { // watch mode should not exit. Log instead. this.logFailure(error.message); }, })); return; } const services = await this.resolveServices(serviceInputs); if (isRemote) { let registry, token; try { registry = this.ensure({ key: 'registry.endpoint', legacyFlagName: 'registry', args: flags, defaultValue: config_1.graphqlEndpoint, env: 'HIVE_REGISTRY', description: Dev.flags['registry.endpoint'].description, }); } catch (e) { throw new errors_1.MissingEndpointError(); } try { token = this.ensure({ key: 'registry.accessToken', legacyFlagName: 'token', args: flags, env: 'HIVE_TOKEN', description: Dev.flags['registry.accessToken'].description, }); } catch (e) { throw new errors_1.MissingRegistryTokenError(); } return this.compose({ services, registry, token, write: flags.write, unstable__forceLatest, target, onError: error => { throw error; }, }); } return this.composeLocally({ services, write: flags.write, onError: error => { throw error; }, }); } async composeLocally(input) { const compositionResult = await new Promise((resolve, reject) => { try { resolve((0, federation_composition_1.composeServices)(input.services.map(service => ({ name: service.name, url: service.url, typeDefs: (0, graphql_1.parse)(service.sdl), })))); } catch (error) { // @note: composeServices should not throw. // This reject is for the offchance that something happens under the hood that was not expected. // Without it, if something happened then the promise would hang. reject(error); } }); if ((0, federation_composition_1.compositionHasErrors)(compositionResult)) { input.onError(new errors_1.LocalCompositionError(compositionResult)); return; } this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); await (0, promises_1.writeFile)((0, node_path_1.resolve)(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); } async compose(input) { const result = await this.registryApi(input.registry, input.token).request({ operation: CLI_SchemaComposeMutation, variables: { input: { useLatestComposableVersion: !input.unstable__forceLatest, services: input.services.map(service => ({ name: service.name, url: service.url, sdl: service.sdl, })), target: input.target, }, }, }); if (result.schemaCompose.__typename === 'SchemaComposeError') { input.onError(new errors_1.APIError(result.schemaCompose.message)); return; } const { valid, compositionResult } = result.schemaCompose; if (!valid) { // @note: Can this actually be invalid without any errors? if (compositionResult.errors) { input.onError(new errors_1.RemoteCompositionError(compositionResult.errors)); return; } input.onError(new errors_1.InvalidCompositionResultError(compositionResult.supergraphSdl)); return; } if (typeof compositionResult.supergraphSdl !== 'string') { input.onError(new errors_1.InvalidCompositionResultError(compositionResult.supergraphSdl)); return; } this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); try { await (0, promises_1.writeFile)((0, node_path_1.resolve)(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); } catch (e) { input.onError(new errors_1.UnexpectedError(e)); } } async watch(watchInterval, serviceInputs, compose) { this.logInfo('Watch mode enabled'); let services; try { services = await this.resolveServices(serviceInputs); await compose(services); } catch (e) { throw new errors_1.UnexpectedError(e); } this.logInfo('Watching for changes'); let resolveWatchMode; const watchPromise = new Promise(resolve => { resolveWatchMode = resolve; }); let timeoutId; const watch = async () => { try { const newServices = await this.resolveServices(serviceInputs); if (newServices.some(service => services.find(s => s.name === service.name).sdl !== service.sdl)) { this.logInfo('Detected changes, recomposing'); await compose(newServices); services = newServices; } } catch (error) { this.logFailure(new errors_1.UnexpectedError(error)); } timeoutId = setTimeout(watch, watchInterval); }; process.once('SIGINT', () => { this.logInfo('Exiting watch mode'); clearTimeout(timeoutId); resolveWatchMode(); }); process.once('SIGTERM', () => { this.logInfo('Exiting watch mode'); clearTimeout(timeoutId); resolveWatchMode(); }); void watch(); return watchPromise; } async resolveServices(services) { return await Promise.all(services.map(async (input) => { if (input.sdl) { return { name: input.name, url: input.url, sdl: await this.resolveSdlFromPath(input.sdl), input: { kind: 'file', path: input.sdl, }, }; } return { name: input.name, url: input.url, sdl: await this.resolveSdlFromUrl(input.url), input: { kind: 'url', url: input.url, }, }; })); } async resolveSdlFromPath(path) { const sdl = await (0, schema_1.loadSchema)(path); (0, validation_1.invariant)(typeof sdl === 'string' && sdl.length > 0, `Read empty schema from ${path}`); return sdl; } async resolveSdlFromUrl(url) { const result = await this.graphql(url).request({ operation: ServiceIntrospectionQuery }); const sdl = result._service.sdl; if (!sdl) { throw new errors_1.IntrospectionError(); } return sdl; } } Dev.description = [ 'Develop and compose Supergraph with your local services.', 'Only available for Federation projects.', '', 'Two modes are available:', " 1. Local mode (default): Compose provided services locally. (Uses Hive's native Federation v2 composition)", ' 2. Remote mode: Perform composition remotely (according to project settings) using all services registered in the registry.', '', 'Work in Progress: Please note that this command is still under development and may undergo changes in future releases', ].join('\n'); Dev.flags = { 'registry.endpoint': core_1.Flags.string({ description: 'registry endpoint', dependsOn: ['remote'], }), /** @deprecated */ registry: core_1.Flags.string({ description: 'registry address (deprecated in favor of --registry.endpoint)', deprecated: { message: 'use --registry.endpoint instead', version: '0.21.0', }, dependsOn: ['remote'], }), 'registry.accessToken': core_1.Flags.string({ description: 'registry access token', dependsOn: ['remote'], }), /** @deprecated */ token: core_1.Flags.string({ description: 'api token (deprecated in favor of --registry.accessToken)', deprecated: { message: 'use --registry.accessToken instead', version: '0.21.0', }, dependsOn: ['remote'], }), service: core_1.Flags.string({ description: 'Service name', required: true, multiple: true, helpValue: '<string>', }), url: core_1.Flags.string({ description: 'Service url', required: true, multiple: true, helpValue: '<address>', dependsOn: ['service'], }), schema: core_1.Flags.string({ description: 'Service sdl. If not provided, will be introspected from the service', multiple: true, helpValue: '<filepath>', dependsOn: ['service'], }), watch: core_1.Flags.boolean({ description: 'Watch mode', default: false, }), watchInterval: core_1.Flags.integer({ description: 'Watch interval in milliseconds', default: 1000, }), write: core_1.Flags.string({ description: 'Where to save the supergraph schema file', default: 'supergraph.graphql', }), remote: core_1.Flags.boolean({ // TODO: improve description description: 'Compose provided services remotely', default: false, }), unstable__forceLatest: core_1.Flags.boolean({ hidden: true, description: 'Force the command to use the latest version of the CLI, not the latest composable version.', default: false, dependsOn: ['remote'], }), target: core_1.Flags.string({ description: 'The target to use for composition (slug or ID).' + ' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' + ' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").', }), }; exports.default = Dev; //# sourceMappingURL=dev.js.map