Skip to content

Commit

Permalink
Allow to compose local subgraphs with subgraphs from the registry (#4383
Browse files Browse the repository at this point in the history
)
  • Loading branch information
kamilkisiela committed Apr 4, 2024
1 parent bc7bb14 commit ff480e9
Show file tree
Hide file tree
Showing 11 changed files with 993 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .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.
1 change: 1 addition & 0 deletions .prettierignore
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions integration-tests/testkit/cli.ts
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
};
}
283 changes: 283 additions & 0 deletions 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');
});
});

0 comments on commit ff480e9

Please sign in to comment.