From ff480e917337b3dd0c7eb3a51254ca2bac8bddcd Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Thu, 4 Apr 2024 09:18:57 +0200 Subject: [PATCH] Allow to compose local subgraphs with subgraphs from the registry (#4383) --- .changeset/pink-lamps-melt.md | 5 + .prettierignore | 1 + integration-tests/testkit/cli.ts | 38 ++ integration-tests/tests/cli/dev.spec.ts | 283 +++++++++++++ packages/libraries/cli/README.md | 51 ++- packages/libraries/cli/src/base-command.ts | 18 +- packages/libraries/cli/src/commands/dev.ts | 375 ++++++++++++++++++ .../api/src/modules/schema/module.graphql.ts | 40 ++ .../schema/providers/schema-manager.ts | 83 ++++ .../api/src/modules/schema/resolvers.ts | 31 ++ .../docs/src/pages/docs/api-reference/cli.mdx | 86 +++- 11 files changed, 993 insertions(+), 18 deletions(-) create mode 100644 .changeset/pink-lamps-melt.md create mode 100644 integration-tests/tests/cli/dev.spec.ts create mode 100644 packages/libraries/cli/src/commands/dev.ts diff --git a/.changeset/pink-lamps-melt.md b/.changeset/pink-lamps-melt.md new file mode 100644 index 0000000000..52fb95c211 --- /dev/null +++ b/.changeset/pink-lamps-melt.md @@ -0,0 +1,5 @@ +--- +"@graphql-hive/cli": minor +--- + +Introducing `hive dev` command - compose local subgraphs with subgraphs from the registry. diff --git a/.prettierignore b/.prettierignore index db6595761c..f8750626cc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,6 +14,7 @@ __generated__/ /packages/web/app/next.config.mjs /packages/migrations/test/utils/testkit.ts /packages/web/app/storybook-static +/packages/web/docs/out/ # test fixtures integration-tests/fixtures/init-invalid-schema.graphql diff --git a/integration-tests/testkit/cli.ts b/integration-tests/testkit/cli.ts index 2909b71f96..202cb04013 100644 --- a/integration-tests/testkit/cli.ts +++ b/integration-tests/testkit/cli.ts @@ -62,6 +62,14 @@ export async function schemaDelete(args: string[]) { ); } +async function dev(args: string[]) { + const registryAddress = await getServiceHost('server', 8082); + + return await exec( + ['dev', `--registry.endpoint`, `http://${registryAddress}/graphql`, ...args].join(' '), + ); +} + export function createCLI(tokens: { readwrite: string; readonly: string }) { let publishCount = 0; @@ -245,9 +253,39 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { return cmd; } + async function devCmd(input: { + services: Array<{ + name: string; + url: string; + sdl: string; + }>; + write?: string; + useLatestVersion?: boolean; + }) { + return dev([ + '--registry.accessToken', + tokens.readonly, + input.write ? `--write ${input.write}` : '', + input.useLatestVersion ? '--unstable__forceLatest' : '', + ...(await Promise.all( + input.services.map(async ({ name, url, sdl }) => { + return [ + '--service', + name, + '--url', + url, + '--schema', + await generateTmpFile(sdl, 'graphql'), + ].join(' '); + }), + )), + ]); + } + return { publish, check, delete: deleteCommand, + dev: devCmd, }; } diff --git a/integration-tests/tests/cli/dev.spec.ts b/integration-tests/tests/cli/dev.spec.ts new file mode 100644 index 0000000000..7a9677b406 --- /dev/null +++ b/integration-tests/tests/cli/dev.spec.ts @@ -0,0 +1,283 @@ +/* eslint-disable no-process-env */ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ProjectType } from '@app/gql/graphql'; +import { createCLI } from '../../testkit/cli'; +import { initSeed } from '../../testkit/seed'; + +function tmpFile(extension: string) { + const dir = tmpdir(); + const fileName = randomUUID(); + const filepath = join(dir, `${fileName}.${extension}`); + + return { + filepath, + read() { + return readFile(filepath, 'utf-8'); + }, + }; +} + +describe('dev', () => { + test('not available for SINGLE project', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createToken } = await createProject(ProjectType.Single); + const { secret } = await createToken({}); + const cli = createCLI({ readwrite: secret, readonly: secret }); + + const cmd = cli.dev({ + services: [ + { + name: 'foo', + url: 'http://localhost/foo', + sdl: 'type Query { foo: String }', + }, + ], + }); + + await expect(cmd).rejects.toThrowError(/Only Federation projects are supported/); + }); + + test('not available for STITCHING project', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createToken } = await createProject(ProjectType.Stitching); + const { secret } = await createToken({}); + const cli = createCLI({ readwrite: secret, readonly: secret }); + + const cmd = cli.dev({ + services: [ + { + name: 'foo', + url: 'http://localhost/foo', + sdl: 'type Query { foo: String }', + }, + ], + }); + + await expect(cmd).rejects.toThrowError(/Only Federation projects are supported/); + }); + + test('adds a service', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createToken } = await createProject(ProjectType.Federation); + const { secret } = await createToken({}); + const cli = createCLI({ readwrite: secret, readonly: secret }); + + await cli.publish({ + sdl: 'type Query { foo: String }', + serviceName: 'foo', + serviceUrl: 'http://localhost/foo', + expect: 'latest-composable', + }); + + const supergraph = tmpFile('graphql'); + const cmd = cli.dev({ + services: [ + { + name: 'bar', + url: 'http://localhost/bar', + sdl: 'type Query { bar: String }', + }, + ], + write: supergraph.filepath, + }); + + await expect(cmd).resolves.toMatch(supergraph.filepath); + await expect(supergraph.read()).resolves.toMatch('http://localhost/bar'); + }); + + test('replaces a service', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createToken } = await createProject(ProjectType.Federation); + const { secret } = await createToken({}); + const cli = createCLI({ readwrite: secret, readonly: secret }); + + await cli.publish({ + sdl: 'type Query { foo: String }', + serviceName: 'foo', + serviceUrl: 'http://example.com/foo', + expect: 'latest-composable', + }); + + await cli.publish({ + sdl: 'type Query { bar: String }', + serviceName: 'bar', + serviceUrl: 'http://example.com/bar', + expect: 'latest-composable', + }); + + const supergraph = tmpFile('graphql'); + const cmd = cli.dev({ + services: [ + { + name: 'bar', + url: 'http://localhost/bar', + sdl: 'type Query { bar: String }', + }, + ], + write: supergraph.filepath, + }); + + await expect(cmd).resolves.toMatch(supergraph.filepath); + await expect(supergraph.read()).resolves.toMatch('http://localhost/bar'); + }); + + test('uses latest composable version by default', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + const { createToken, setNativeFederation } = await createProject(ProjectType.Federation); + const { secret } = await createToken({}); + const cli = createCLI({ readwrite: secret, readonly: secret }); + + // Once we ship native federation v2 composition by default, we can remove these two lines + await setFeatureFlag('compareToPreviousComposableVersion', true); + await setNativeFederation(true); + + await cli.publish({ + sdl: /* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + foo: String + } + + type User @key(fields: "id") { + id: ID! + foo: String! + } + `, + serviceName: 'foo', + serviceUrl: 'http://example.com/foo', + expect: 'latest-composable', + }); + + // contains a non-shareable field + await cli.publish({ + sdl: /* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + bar: String + } + + type User @key(fields: "id") { + id: ID! + foo: String! + } + `, + serviceName: 'bar', + serviceUrl: 'http://example.com/bar', + expect: 'latest', + }); + + const supergraph = tmpFile('graphql'); + const cmd = cli.dev({ + services: [ + { + name: 'baz', + url: 'http://localhost/baz', + sdl: /* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + baz: String + } + + type User @key(fields: "id") { + id: ID! + baz: String! + } + `, + }, + ], + write: supergraph.filepath, + }); + + await expect(cmd).resolves.toMatch(supergraph.filepath); + const content = await supergraph.read(); + expect(content).not.toMatch('http://localhost/bar'); + expect(content).toMatch('http://localhost/baz'); + }); + + test('uses latest version when requested', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + const { createToken, setNativeFederation } = await createProject(ProjectType.Federation); + const { secret } = await createToken({}); + const cli = createCLI({ readwrite: secret, readonly: secret }); + + // Once we ship native federation v2 composition by default, we can remove these two lines + await setFeatureFlag('compareToPreviousComposableVersion', true); + await setNativeFederation(true); + + await cli.publish({ + sdl: /* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + foo: String + } + + type User @key(fields: "id") { + id: ID! + foo: String! + } + `, + serviceName: 'foo', + serviceUrl: 'http://example.com/foo', + expect: 'latest-composable', + }); + + // contains a non-shareable field + await cli.publish({ + sdl: /* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + bar: String + } + + type User @key(fields: "id") { + id: ID! + foo: String! + } + `, + serviceName: 'bar', + serviceUrl: 'http://example.com/bar', + expect: 'latest', + }); + + const supergraph = tmpFile('graphql'); + const cmd = cli.dev({ + useLatestVersion: true, + services: [ + { + name: 'baz', + url: 'http://localhost/baz', + sdl: /* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + baz: String + } + + type User @key(fields: "id") { + id: ID! + baz: String! + } + `, + }, + ], + write: supergraph.filepath, + }); + + // The command should fail because the latest version contains a non-shareable field and we don't override the corrupted subgraph + await expect(cmd).rejects.toThrowError('Non-shareable field'); + }); +}); diff --git a/packages/libraries/cli/README.md b/packages/libraries/cli/README.md index 37c739bfc8..ad594afa10 100644 --- a/packages/libraries/cli/README.md +++ b/packages/libraries/cli/README.md @@ -39,6 +39,7 @@ curl -sSL https://graphql-hive.com/install.sh | sh - [`hive config:get KEY`](#hive-configget-key) - [`hive config:reset`](#hive-configreset) - [`hive config:set KEY VALUE`](#hive-configset-key-value) +- [`hive dev`](#hive-dev) - [`hive help [COMMANDS]`](#hive-help-commands) - [`hive introspect LOCATION`](#hive-introspect-location) - [`hive operations:check FILE`](#hive-operationscheck-file) @@ -70,7 +71,7 @@ DESCRIPTION ``` _See code: -[dist/commands/artifact/fetch.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/artifact/fetch.js)_ +[dist/commands/artifact/fetch.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/artifact/fetch.js)_ ## `hive config:delete KEY` @@ -88,7 +89,7 @@ DESCRIPTION ``` _See code: -[dist/commands/config/delete.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/config/delete.js)_ +[dist/commands/config/delete.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/config/delete.js)_ ## `hive config:get KEY` @@ -106,7 +107,7 @@ DESCRIPTION ``` _See code: -[dist/commands/config/get.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/config/get.js)_ +[dist/commands/config/get.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/config/get.js)_ ## `hive config:reset` @@ -121,7 +122,7 @@ DESCRIPTION ``` _See code: -[dist/commands/config/reset.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/config/reset.js)_ +[dist/commands/config/reset.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/config/reset.js)_ ## `hive config:set KEY VALUE` @@ -140,7 +141,33 @@ DESCRIPTION ``` _See code: -[dist/commands/config/set.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/config/set.js)_ +[dist/commands/config/set.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/config/set.js)_ + +## `hive dev` + +develop and compose Supergraph with service substitution (only available for Federation projects) + +``` +USAGE + $ hive dev (--url --service ) [--registry.endpoint ] [--registry.accessToken + ] [--schema ] [--watch] [--watchInterval ] [--write ] + +FLAGS + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --schema=... Service sdl. If not provided, will be introspected from the service + --service=... (required) Service name + --url=
... (required) Service url + --watch Watch mode + --watchInterval= [default: 1000] Watch interval in milliseconds + --write= [default: supergraph.graphql] Where to save the supergraph schema file + +DESCRIPTION + develop and compose Supergraph with service substitution (only available for Federation projects) +``` + +_See code: +[dist/commands/dev.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/dev.js)_ ## `hive help [COMMANDS]` @@ -183,7 +210,7 @@ DESCRIPTION ``` _See code: -[dist/commands/introspect.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/introspect.js)_ +[dist/commands/introspect.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/introspect.js)_ ## `hive operations:check FILE` @@ -233,7 +260,7 @@ DESCRIPTION ``` _See code: -[dist/commands/operations/check.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/operations/check.js)_ +[dist/commands/operations/check.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/operations/check.js)_ ## `hive schema:check FILE` @@ -267,7 +294,7 @@ DESCRIPTION ``` _See code: -[dist/commands/schema/check.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/schema/check.js)_ +[dist/commands/schema/check.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/schema/check.js)_ ## `hive schema:delete SERVICE` @@ -294,7 +321,7 @@ DESCRIPTION ``` _See code: -[dist/commands/schema/delete.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/schema/delete.js)_ +[dist/commands/schema/delete.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/schema/delete.js)_ ## `hive schema:fetch ACTIONID` @@ -322,7 +349,7 @@ DESCRIPTION ``` _See code: -[dist/commands/schema/fetch.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/schema/fetch.js)_ +[dist/commands/schema/fetch.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/schema/fetch.js)_ ## `hive schema:publish FILE` @@ -360,7 +387,7 @@ DESCRIPTION ``` _See code: -[dist/commands/schema/publish.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/schema/publish.js)_ +[dist/commands/schema/publish.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/schema/publish.js)_ ## `hive update [CHANNEL]` @@ -420,7 +447,7 @@ DESCRIPTION ``` _See code: -[dist/commands/whoami.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.33.0/dist/commands/whoami.js)_ +[dist/commands/whoami.js](https://github.com/kamilkisiela/graphql-hive/blob/v0.34.1/dist/commands/whoami.js)_ diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index e1400f7d72..a5649498d3 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -145,20 +145,28 @@ export default abstract class extends Command { registryApi(registry: string, token: string) { const requestHeaders = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': `hive-cli/${this.config.version}`, Authorization: `Bearer ${token}`, 'graphql-client-name': 'Hive CLI', 'graphql-client-version': this.config.version, }; + return this.graphql(registry, requestHeaders); + } + + graphql(endpoint: string, additionalHeaders: Record = {}) { + const requestHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': `hive-cli/${this.config.version}`, + ...additionalHeaders, + }; + return { async request( operation: TypedDocumentNode, ...[variables]: TVariables extends Record ? [] : [TVariables] ): Promise { - const response = await fetch(registry, { + const response = await fetch(endpoint, { headers: requestHeaders, method: 'POST', body: JSON.stringify({ @@ -168,7 +176,7 @@ export default abstract class extends Command { }); if (!response.ok) { - throw new Error(`Invalid status code for registry HTTP call: ${response.status}`); + throw new Error(`Invalid status code for HTTP call: ${response.status}`); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const jsonData = (await response.json()) as ExecutionResult; diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts new file mode 100644 index 0000000000..ed835b0ff1 --- /dev/null +++ b/packages/libraries/cli/src/commands/dev.ts @@ -0,0 +1,375 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { Flags } from '@oclif/core'; +import Command from '../base-command'; +import { graphql } from '../gql'; +import { graphqlEndpoint } from '../helpers/config'; +import { loadSchema, renderErrors } from '../helpers/schema'; +import { invariant } from '../helpers/validation'; + +const CLI_SchemaComposeMutation = 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 + } + } +` as unknown as TypedDocumentNode< + { + __typename?: 'Query'; + _service: { sdl: string }; + }, + { + [key: string]: never; + } +>; + +type ServiceName = string; +type Sdl = string; + +type ServiceInput = { + name: ServiceName; + url: string; + sdl?: string; +}; + +type Service = { + name: ServiceName; + url: string; + sdl: Sdl; +}; + +type ServiceWithSource = { + name: ServiceName; + url: string; + sdl: Sdl; + input: + | { + kind: 'file'; + path: string; + } + | { + kind: 'url'; + url: string; + }; +}; + +export default class Dev extends Command { + static description = [ + 'Develop and compose Supergraph with service substitution', + 'Only available for Federation projects.', + 'Work in Progress: Please note that this command is still under development and may undergo changes in future releases', + ].join('\n'); + static flags = { + 'registry.endpoint': Flags.string({ + description: 'registry endpoint', + }), + /** @deprecated */ + registry: Flags.string({ + description: 'registry address', + deprecated: { + message: 'use --registry.endpoint instead', + version: '0.21.0', + }, + }), + 'registry.accessToken': Flags.string({ + description: 'registry access token', + }), + /** @deprecated */ + token: Flags.string({ + description: 'api token', + deprecated: { + message: 'use --registry.accessToken instead', + version: '0.21.0', + }, + }), + service: Flags.string({ + description: 'Service name', + required: true, + multiple: true, + helpValue: '', + }), + url: Flags.string({ + description: 'Service url', + required: true, + multiple: true, + helpValue: '
', + dependsOn: ['service'], + }), + schema: Flags.string({ + description: 'Service sdl. If not provided, will be introspected from the service', + multiple: true, + helpValue: '', + dependsOn: ['service'], + }), + watch: Flags.boolean({ + description: 'Watch mode', + default: false, + }), + watchInterval: Flags.integer({ + description: 'Watch interval in milliseconds', + default: 1000, + }), + write: Flags.string({ + description: 'Where to save the supergraph schema file', + default: 'supergraph.graphql', + }), + unstable__forceLatest: Flags.boolean({ + hidden: true, + description: + 'Force the command to use the latest version of the CLI, not the latest composable version.', + default: false, + }), + }; + + async run() { + const { flags } = await this.parse(Dev); + + const registry = this.ensure({ + key: 'registry.endpoint', + legacyFlagName: 'registry', + args: flags, + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + }); + const token = this.ensure({ + key: 'registry.accessToken', + legacyFlagName: 'token', + args: flags, + env: 'HIVE_TOKEN', + }); + const { unstable__forceLatest } = flags; + + if (flags.service.length !== flags.url.length) { + this.error('Not every services has a matching url', { + exit: 1, + }); + } + + const serviceInputs = flags.service.map((name, i) => { + const url = flags.url[i]; + const sdl = flags.schema ? flags.schema[i] : undefined; + + return { + name, + url, + sdl, + }; + }); + + if (flags.watch === true) { + void this.watch(flags.watchInterval, serviceInputs, services => + this.compose({ + services, + registry, + token, + write: flags.write, + unstable__forceLatest, + onError: message => { + this.fail(message); + }, + }), + ); + return; + } + + const services = await this.resolveServices(serviceInputs); + + return this.compose({ + services, + registry, + token, + write: flags.write, + unstable__forceLatest, + onError: message => { + this.error(message, { + exit: 1, + }); + }, + }); + } + + private async compose(input: { + services: Array<{ + name: string; + url: string; + sdl: string; + }>; + registry: string; + token: string; + write: string; + unstable__forceLatest: boolean; + onError: (message: string) => void | never; + }) { + const result = await this.registryApi(input.registry, input.token) + .request(CLI_SchemaComposeMutation, { + input: { + useLatestComposableVersion: !input.unstable__forceLatest, + services: input.services.map(service => ({ + name: service.name, + url: service.url, + sdl: service.sdl, + })), + }, + }) + .catch(error => { + this.handleFetchError(error); + }); + + if (result.schemaCompose.__typename === 'SchemaComposeError') { + input.onError(result.schemaCompose.message); + return; + } + + const { valid, compositionResult } = result.schemaCompose; + + if (!valid) { + if (compositionResult.errors) { + renderErrors.call(this, compositionResult.errors); + } + + input.onError('Composition failed'); + return; + } + + if (typeof compositionResult.supergraphSdl !== 'string') { + input.onError( + 'Composition successful but failed to get supergraph schema. Please try again later or contact support', + ); + return; + } + + this.success('Composition successful'); + this.log(`Saving supergraph schema to ${input.write}`); + await writeFile(resolve(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); + } + + private async watch( + watchInterval: number, + serviceInputs: ServiceInput[], + compose: (services: Service[]) => Promise, + ) { + this.info('Watch mode enabled'); + + let services = await this.resolveServices(serviceInputs); + await compose(services); + + this.info('Watching for changes'); + + let resolveWatchMode: () => void; + + const watchPromise = new Promise(resolve => { + resolveWatchMode = resolve; + }); + + let timeoutId: ReturnType; + 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.info('Detected changes, recomposing'); + await compose(newServices); + services = newServices; + } + } catch (error) { + this.fail(String(error)); + } + + timeoutId = setTimeout(watch, watchInterval); + }; + + process.once('SIGINT', () => { + this.info('Exiting watch mode'); + clearTimeout(timeoutId); + resolveWatchMode(); + }); + + process.once('SIGTERM', () => { + this.info('Exiting watch mode'); + clearTimeout(timeoutId); + resolveWatchMode(); + }); + + void watch(); + + return watchPromise; + } + + private async resolveServices(services: ServiceInput[]): Promise> { + 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' as const, + path: input.sdl, + }, + }; + } + + return { + name: input.name, + url: input.url, + sdl: await this.resolveSdlFromUrl(input.url), + input: { + kind: 'url' as const, + url: input.url, + }, + }; + }), + ); + } + + private async resolveSdlFromPath(path: string) { + const sdl = await loadSchema(path); + invariant(typeof sdl === 'string' && sdl.length > 0, `Read empty schema from ${path}`); + + return sdl; + } + + private async resolveSdlFromUrl(url: string) { + const result = await this.graphql(url) + .request(ServiceIntrospectionQuery) + .catch(error => { + this.handleFetchError(error); + }); + + const sdl = result._service.sdl; + + if (!sdl) { + throw new Error('Failed to introspect service'); + } + + return sdl; + } +} diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index f6765b7322..323717f7a0 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -14,6 +14,11 @@ export default gql` Requires API Token """ schemaDelete(input: SchemaDeleteInput!): SchemaDeleteResult! + """ + Requires API Token + """ + schemaCompose(input: SchemaComposeInput!): SchemaComposePayload! + updateSchemaVersionStatus(input: SchemaVersionUpdateInput!): SchemaVersion! updateBaseSchema(input: UpdateBaseSchemaInput!): UpdateBaseSchemaResult! updateNativeFederation(input: UpdateNativeFederationInput!): UpdateNativeFederationResult! @@ -328,6 +333,41 @@ export default gql` gitHub: SchemaPublishGitHubInput } + input SchemaComposeInput { + services: [SchemaComposeServiceInput!]! + """ + Whether to use the latest composable version or just latest schema version for the composition. + Latest schema version may or may not be composable. + It's true by default, which means the latest composable schema version is used. + """ + useLatestComposableVersion: Boolean = true + } + + input SchemaComposeServiceInput { + name: String! + sdl: String! + url: String + } + + union SchemaComposePayload = SchemaComposeSuccess | SchemaComposeError + + type SchemaComposeSuccess { + valid: Boolean! + compositionResult: SchemaCompositionResult! + } + + """ + @oneOf + """ + type SchemaCompositionResult { + supergraphSdl: String + errors: SchemaErrorConnection + } + + type SchemaComposeError implements Error { + message: String! + } + union SchemaCheckPayload = | SchemaCheckSuccess | SchemaCheckError diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index ac928e2c26..30d590f032 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -97,6 +97,89 @@ export class SchemaManager { return this.storage.hasSchema(selector); } + async compose( + input: TargetSelector & { + onlyComposable: boolean; + services: ReadonlyArray<{ + sdl: string; + url?: string | null; + name: string; + }>; + }, + ) { + this.logger.debug('Composing schemas (input=%o)', lodash.omit(input, 'services')); + await this.authManager.ensureTargetAccess({ + ...input, + scope: TargetAccessScope.REGISTRY_READ, + }); + + const [organization, project, latestSchemas] = await Promise.all([ + this.organizationManager.getOrganization({ + organization: input.organization, + }), + this.projectManager.getProject({ + organization: input.organization, + project: input.project, + }), + this.getLatestSchemas({ + organization: input.organization, + project: input.project, + target: input.target, + onlyComposable: input.onlyComposable, + }), + ]); + + if (project.type !== ProjectType.FEDERATION) { + return { + kind: 'error' as const, + message: 'Only Federation projects are supported', + }; + } + + const orchestrator = this.matchOrchestrator(project.type); + + const existingServices = ensureCompositeSchemas(latestSchemas ? latestSchemas.schemas : []); + const services = existingServices + // remove provided services from the list + .filter(service => !input.services.some(s => s.name === service.service_name)) + .map(service => ({ + service_name: service.service_name, + sdl: service.sdl, + service_url: service.service_url, + })) + // add provided services to the list + .concat( + input.services.map(service => ({ + service_name: service.name, + sdl: service.sdl, + service_url: service.url ?? null, + })), + ) + .map(service => this.schemaHelper.createSchemaObject(service)); + + const compositionResult = await orchestrator.composeAndValidate(services, { + external: project.externalComposition, + native: this.checkProjectNativeFederationSupport({ project, organization }), + contracts: null, + }); + + if (compositionResult.errors.length > 0) { + return { + kind: 'success' as const, + errors: compositionResult.errors, + }; + } + + if (compositionResult.supergraph) { + return { + kind: 'success' as const, + supergraphSDL: compositionResult.supergraph, + }; + } + + throw new Error('Composition was successful but is missing a supergraph'); + } + @atomic(stringifySelector) async getSchemasOfVersion( selector: { diff --git a/packages/services/api/src/modules/schema/resolvers.ts b/packages/services/api/src/modules/schema/resolvers.ts index aa29d82634..883152b34a 100644 --- a/packages/services/api/src/modules/schema/resolvers.ts +++ b/packages/services/api/src/modules/schema/resolvers.ts @@ -275,6 +275,37 @@ export const resolvers: SchemaModule.Resolvers = { })), }; }, + async schemaCompose(_, { input }, { injector }) { + const [organization, project, target] = await Promise.all([ + injector.get(OrganizationManager).getOrganizationIdByToken(), + injector.get(ProjectManager).getProjectIdByToken(), + injector.get(TargetManager).getTargetIdByToken(), + ]); + + const result = await injector.get(SchemaManager).compose({ + onlyComposable: input.useLatestComposableVersion === true, + services: input.services, + organization, + project, + target, + }); + + if (result.kind === 'error') { + return { + __typename: 'SchemaComposeError', + message: result.message, + }; + } + + return { + __typename: 'SchemaComposeSuccess', + valid: 'supergraphSDL' in result && result.supergraphSDL !== null, + compositionResult: { + errors: result.errors, + supergraphSdl: result.supergraphSDL, + }, + }; + }, async updateSchemaVersionStatus(_, { input }, { injector }) { const translator = injector.get(IdTranslator); const [organization, project, target] = await Promise.all([ diff --git a/packages/web/docs/src/pages/docs/api-reference/cli.mdx b/packages/web/docs/src/pages/docs/api-reference/cli.mdx index 46ab10522b..00ce84801b 100644 --- a/packages/web/docs/src/pages/docs/api-reference/cli.mdx +++ b/packages/web/docs/src/pages/docs/api-reference/cli.mdx @@ -171,7 +171,7 @@ Further reading: Start by setting your Hive token in [`hive.json`](/docs/api-reference/cli#config-file-hivejson) file, or set it as `HIVE_TOKEN` environment variable. -In case you want to delete a schema (or a subgraph in case of Federation), you can do so by using +In case you want to compose a schema (or a subgraph in case of Federation), you can do so by using the `hive schema:delete` command. ```bash @@ -187,6 +187,90 @@ In case you want to confirm deletion of the service without typing anything in t +### Develop schema locally + + + This CLI command requires an active registry token with **Read** permissions to the target and the + project. + + +This action is only available for Apollo Federation projects. + +This command enables you to replace the subgraph(s) available in the Registry with your local +subgraph(s) and compose a Supergraph. + +```mermaid + +flowchart LR + subgraph local["Local environment"] + G["Gateway"] -. Poll supergraph from a file .-> S["supergraph.graphql"] + SA -.- CLI + G --> SA[subgraph A] + end + + subgraph dev["Dev"] + G --> SB[subgraph B] + G --> SC[subgraph C] + SB -.- CLI[Hive CLI] + SC -.- CLI + end + + subgraph Hive + CLI -- dev command --> R + R[Registry] -. outputs .-> S + end + + +``` + +Rather than uploading your local schema to the registry and retrieving the supergraph from the CDN, +you can integrate your local modifications directly into the supergraph. + +The result of executing this command is a file containing the Supergraph SDL, which can be feed into +the gateway. + +```bash +# Introspect the SDL of the local service +hive dev --service reviews --url http://localhost:3001/graphql + +# Watch mode +hive dev --watch --service reviews --url http://localhost:3001/graphql + +# Provide the SDL of the local service +hive dev --service reviews --url http://localhost:3001/graphql --schema reviews.graphql + +# or with multiple services +hive dev \ + --service reviews --url http://localhost:3001/graphql \ + --service products --url http://localhost:3002/graphql --schema products.graphql + +# Custom output file (default: supergraph.graphql) +hive dev --service reviews --url http://localhost:3001/graphql --write local-supergraph.graphql +``` + +#### Usage example + +Let's say you have two subgraphs, `reviews` and `products`, and you want to test the `reviews` +service. + +First, you need to start the `reviews` service locally and then run the following command: + +```bash +hive dev --watch --service reviews --url http://localhost:3001/graphql +``` + +This command will fetch subgraph's schema from the provided URL, replace the original `reviews` +subgraph from the Registry with the local one, and compose a supergraph. The outcome will be saved +in the `supergraph.graphql` file. + +The `products` subgraph will stay untoched, meaing that the gateway will route requests to its +remote endpoint. + +> The `--watch` flag will keep the process running and update the supergraph whenever the local +> schema changes. + +Now you're ready to use the `supergraph.graphql` file in your gateway and execute queries. + ### Git Metadata If you are running `hive` command line in a directory that has a Git repository configured (`.git`),