@graphql-hive/cli
Version:
A CLI util to manage and control your GraphQL Hive
403 lines • 14.9 kB
JavaScript
"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