Skip to content

Commit 0504530

Browse files
feat: allow non target access actors to check, compose, delete and publish schemas (#6449)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 123df7a commit 0504530

38 files changed

+810
-325
lines changed

.changeset/hungry-files-sneeze.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
Modify GraphQL fields used by CLI to accept an optional specified target that is used for
6+
identifying the affected target instead of resolving the target from the access token.

.changeset/wet-eggs-rule.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@graphql-hive/cli': minor
3+
---
4+
5+
Add `--target` flag for commands `app:create`, `app:publish`, `operations:check`, `schema:check`,
6+
`schema:delete`, `schema:fetch`, `schema:publish` and `dev`.
7+
8+
The `--target` flag can be used to specify the target on which the operation should be performed.
9+
Either a slug or ID of the target can be provided.
10+
11+
A provided slug must follow the format `$organizationSlug/$projectSlug/$targetSlug` (e.g.
12+
`the-guild/graphql-hive/staging`).
13+
14+
**Example using target slug**
15+
16+
```bash
17+
hive schema:publish --target the-guild/graphql-hive/production ./my-schema.graphql
18+
```
19+
20+
A target id, must be a valid target UUID.
21+
22+
**Example using target id**
23+
24+
```bash
25+
hive schema:publish --target a0f4c605-6541-4350-8cfe-b31f21a4bf80 ./my-schema.graphql
26+
```
27+
28+
**Note:** We encourage starting to use the `--target` flag today. In the future the flag will become
29+
mandatory as we are moving to a more flexible approach of access tokens that can be granted access
30+
to multiple targets.

integration-tests/testkit/cli-snapshot-serializer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,18 @@ const variableReplacements = [
4545
pattern: /(Reference: )[^ ]+/gi,
4646
mask: '$1__ID__',
4747
},
48+
{
49+
pattern: /(Request ID:)[^)]+"/gi,
50+
mask: '$1 __REQUEST_ID__',
51+
},
4852
{
4953
pattern: /(https?:\/\/)[^\n ]+/gi,
5054
mask: '$1__URL__',
5155
},
56+
{
57+
pattern: /[ ]+\n/gi,
58+
mask: '\n',
59+
},
5260
];
5361

5462
/**

integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ exports[`FEDERATION > schema:publish should see Invalid Token error when token i
163163
exitCode------------------------------------------:
164164
2
165165
stderr--------------------------------------------:
166-
› Error: A valid registry token is required to perform the action. The
166+
› Error: A valid registry token is required to perform the action. The
167167
› registry token used does not exist or has been revoked. [106]
168168
› > See https://__URL__ for
169169
› a complete list of error codes and recommended fixes.
@@ -319,7 +319,7 @@ exports[`SINGLE > schema:publish should see Invalid Token error when token is in
319319
exitCode------------------------------------------:
320320
2
321321
stderr--------------------------------------------:
322-
› Error: A valid registry token is required to perform the action. The
322+
› Error: A valid registry token is required to perform the action. The
323323
› registry token used does not exist or has been revoked. [106]
324324
› > See https://__URL__ for
325325
› a complete list of error codes and recommended fixes.
@@ -493,7 +493,7 @@ exports[`STITCHING > schema:publish should see Invalid Token error when token is
493493
exitCode------------------------------------------:
494494
2
495495
stderr--------------------------------------------:
496-
› Error: A valid registry token is required to perform the action. The
496+
› Error: A valid registry token is required to perform the action. The
497497
› registry token used does not exist or has been revoked. [106]
498498
› > See https://__URL__ for
499499
› a complete list of error codes and recommended fixes.

integration-tests/tests/cli/schema.spec.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ describe.each([ProjectType.Stitching, ProjectType.Federation, ProjectType.Single
328328
const { inviteAndJoinMember, createProject } = await createOrg();
329329
await inviteAndJoinMember();
330330
const { createTargetAccessToken } = await createProject(projectType);
331-
const { secret, latestSchema } = await createTargetAccessToken({});
331+
const { secret } = await createTargetAccessToken({});
332332

333333
const cli = createCLI({
334334
readonly: secret,
@@ -355,3 +355,101 @@ describe.each([ProjectType.Stitching, ProjectType.Federation, ProjectType.Single
355355
);
356356
},
357357
);
358+
359+
test.concurrent(
360+
'schema:publish with --target parameter matching the access token (slug)',
361+
async ({ expect }) => {
362+
const { createOrg } = await initSeed().createOwner();
363+
const { inviteAndJoinMember, createProject, organization } = await createOrg();
364+
await inviteAndJoinMember();
365+
const { createTargetAccessToken, project, target } = await createProject();
366+
const { secret } = await createTargetAccessToken({});
367+
368+
const targetSlug = [organization.slug, project.slug, target.slug].join('/');
369+
370+
await expect(
371+
schemaPublish([
372+
'--registry.accessToken',
373+
secret,
374+
'--author',
375+
'Kamil',
376+
'--target',
377+
targetSlug,
378+
'fixtures/init-schema.graphql',
379+
]),
380+
).resolves.toMatchInlineSnapshot(`
381+
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::
382+
383+
stdout--------------------------------------------:
384+
✔ Published initial schema.
385+
ℹ Available at http://__URL__
386+
`);
387+
},
388+
);
389+
390+
test.concurrent(
391+
'schema:publish with --target parameter matching the access token (UUID)',
392+
async ({ expect }) => {
393+
const { createOrg } = await initSeed().createOwner();
394+
const { inviteAndJoinMember, createProject } = await createOrg();
395+
await inviteAndJoinMember();
396+
const { createTargetAccessToken, target } = await createProject();
397+
const { secret } = await createTargetAccessToken({});
398+
399+
await expect(
400+
schemaPublish([
401+
'--registry.accessToken',
402+
secret,
403+
'--author',
404+
'Kamil',
405+
'--target',
406+
target.id,
407+
'fixtures/init-schema.graphql',
408+
]),
409+
).resolves.toMatchInlineSnapshot(`
410+
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::
411+
412+
stdout--------------------------------------------:
413+
✔ Published initial schema.
414+
ℹ Available at http://__URL__
415+
`);
416+
},
417+
);
418+
419+
test.concurrent(
420+
'schema:publish fails with --target parameter not matching the access token (slug)',
421+
async ({ expect }) => {
422+
const { createOrg } = await initSeed().createOwner();
423+
const { inviteAndJoinMember, createProject } = await createOrg();
424+
await inviteAndJoinMember();
425+
const { createTargetAccessToken } = await createProject();
426+
const { secret } = await createTargetAccessToken({});
427+
428+
const targetSlug = 'i/do/not-match';
429+
430+
await expect(
431+
schemaPublish([
432+
'--registry.accessToken',
433+
secret,
434+
'--author',
435+
'Kamil',
436+
'--target',
437+
targetSlug,
438+
'fixtures/init-schema.graphql',
439+
]),
440+
).rejects.toMatchInlineSnapshot(`
441+
:::::::::::::::: CLI FAILURE OUTPUT :::::::::::::::
442+
exitCode------------------------------------------:
443+
2
444+
stderr--------------------------------------------:
445+
› Error: No access (reason: "Missing permission for performing
446+
› 'schemaVersion:publish' on resource") (Request ID: __REQUEST_ID__) [115]
447+
› > See https://__URL__ for
448+
› a complete list of error codes and recommended fixes.
449+
› To disable this message set HIVE_NO_ERROR_TIP=1
450+
› Reference: __ID__
451+
stdout--------------------------------------------:
452+
__NONE__
453+
`);
454+
},
455+
);

packages/libraries/cli/src/commands/app/create.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { Args, Flags } from '@oclif/core';
33
import Command from '../../base-command';
44
import { graphql } from '../../gql';
55
import { AppDeploymentStatus } from '../../gql/graphql';
6+
import * as GraphQLSchema from '../../gql/graphql';
67
import { graphqlEndpoint } from '../../helpers/config';
78
import {
89
APIError,
10+
InvalidTargetError,
911
MissingEndpointError,
1012
MissingRegistryTokenError,
1113
PersistedOperationsMalformedError,
1214
} from '../../helpers/errors';
15+
import * as TargetInput from '../../helpers/target-input';
1316

1417
export default class AppCreate extends Command<typeof AppCreate> {
1518
static description = 'create an app deployment';
@@ -28,6 +31,12 @@ export default class AppCreate extends Command<typeof AppCreate> {
2831
description: 'app version',
2932
required: true,
3033
}),
34+
target: Flags.string({
35+
description:
36+
'The target in which the app deployment will be created.' +
37+
' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' +
38+
' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").',
39+
}),
3140
};
3241

3342
static args = {
@@ -66,6 +75,15 @@ export default class AppCreate extends Command<typeof AppCreate> {
6675
throw new MissingRegistryTokenError();
6776
}
6877

78+
let target: GraphQLSchema.TargetReferenceInput | null = null;
79+
if (flags.target) {
80+
const result = TargetInput.parse(flags.target);
81+
if (result.type === 'error') {
82+
throw new InvalidTargetError();
83+
}
84+
target = result.data;
85+
}
86+
6987
const file: string = args.file;
7088
const contents = this.readJSON(file);
7189
const operations: unknown = JSON.parse(contents);
@@ -81,6 +99,7 @@ export default class AppCreate extends Command<typeof AppCreate> {
8199
input: {
82100
appName: flags['name'],
83101
appVersion: flags['version'],
102+
target,
84103
},
85104
},
86105
});
@@ -108,6 +127,7 @@ export default class AppCreate extends Command<typeof AppCreate> {
108127
operation: AddDocumentsToAppDeploymentMutation,
109128
variables: {
110129
input: {
130+
target,
111131
appName: flags['name'],
112132
appVersion: flags['version'],
113133
documents: buffer,

packages/libraries/cli/src/commands/app/publish.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { Flags } from '@oclif/core';
22
import Command from '../../base-command';
33
import { graphql } from '../../gql';
4+
import * as GraphQLSchema from '../../gql/graphql';
45
import { graphqlEndpoint } from '../../helpers/config';
5-
import { APIError, MissingEndpointError, MissingRegistryTokenError } from '../../helpers/errors';
6+
import {
7+
APIError,
8+
InvalidTargetError,
9+
MissingEndpointError,
10+
MissingRegistryTokenError,
11+
} from '../../helpers/errors';
12+
import * as TargetInput from '../../helpers/target-input';
613

714
export default class AppPublish extends Command<typeof AppPublish> {
815
static description = 'publish an app deployment';
@@ -21,6 +28,12 @@ export default class AppPublish extends Command<typeof AppPublish> {
2128
description: 'app version',
2229
required: true,
2330
}),
31+
target: Flags.string({
32+
description:
33+
'The target in which the app deployment will be published (slug or ID).' +
34+
' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' +
35+
' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").',
36+
}),
2437
};
2538

2639
async run() {
@@ -50,10 +63,20 @@ export default class AppPublish extends Command<typeof AppPublish> {
5063
throw new MissingRegistryTokenError();
5164
}
5265

66+
let target: GraphQLSchema.TargetReferenceInput | null = null;
67+
if (flags.target) {
68+
const result = TargetInput.parse(flags.target);
69+
if (result.type === 'error') {
70+
throw new InvalidTargetError();
71+
}
72+
target = result.data;
73+
}
74+
5375
const result = await this.registryApi(endpoint, accessToken).request({
5476
operation: ActivateAppDeploymentMutation,
5577
variables: {
5678
input: {
79+
target,
5780
appName: flags['name'],
5881
appVersion: flags['version'],
5982
},

packages/libraries/cli/src/commands/dev.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {
1010
} from '@theguild/federation-composition';
1111
import Command from '../base-command';
1212
import { graphql } from '../gql';
13+
import * as GraphQLSchema from '../gql/graphql';
1314
import { graphqlEndpoint } from '../helpers/config';
1415
import {
1516
APIError,
1617
HiveCLIError,
1718
IntrospectionError,
1819
InvalidCompositionResultError,
20+
InvalidTargetError,
1921
LocalCompositionError,
2022
MissingEndpointError,
2123
MissingRegistryTokenError,
@@ -24,6 +26,7 @@ import {
2426
UnexpectedError,
2527
} from '../helpers/errors';
2628
import { loadSchema } from '../helpers/schema';
29+
import * as TargetInput from '../helpers/target-input';
2730
import { invariant } from '../helpers/validation';
2831

2932
const CLI_SchemaComposeMutation = graphql(/* GraphQL */ `
@@ -172,10 +175,16 @@ export default class Dev extends Command<typeof Dev> {
172175
unstable__forceLatest: Flags.boolean({
173176
hidden: true,
174177
description:
175-
'Force the command to use the latest version of the CLI, not the latest composable version. ',
178+
'Force the command to use the latest version of the CLI, not the latest composable version.',
176179
default: false,
177180
dependsOn: ['remote'],
178181
}),
182+
target: Flags.string({
183+
description:
184+
'The target to use for composition (slug or ID).' +
185+
' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' +
186+
' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").',
187+
}),
179188
};
180189

181190
async run() {
@@ -200,6 +209,15 @@ export default class Dev extends Command<typeof Dev> {
200209
};
201210
});
202211

212+
let target: GraphQLSchema.TargetReferenceInput | null = null;
213+
if (flags.target) {
214+
const result = TargetInput.parse(flags.target);
215+
if (result.type === 'error') {
216+
throw new InvalidTargetError();
217+
}
218+
target = result.data;
219+
}
220+
203221
if (flags.watch === true) {
204222
if (isRemote) {
205223
let registry: string, token: string;
@@ -234,6 +252,7 @@ export default class Dev extends Command<typeof Dev> {
234252
token,
235253
write: flags.write,
236254
unstable__forceLatest,
255+
target,
237256
onError: error => {
238257
// watch mode should not exit. Log instead.
239258
this.logFailure(error.message);
@@ -291,6 +310,7 @@ export default class Dev extends Command<typeof Dev> {
291310
token,
292311
write: flags.write,
293312
unstable__forceLatest,
313+
target,
294314
onError: error => {
295315
throw error;
296316
},
@@ -354,6 +374,7 @@ export default class Dev extends Command<typeof Dev> {
354374
token: string;
355375
write: string;
356376
unstable__forceLatest: boolean;
377+
target: GraphQLSchema.TargetReferenceInput | null;
357378
onError: (error: HiveCLIError) => void | never;
358379
}) {
359380
const result = await this.registryApi(input.registry, input.token).request({
@@ -366,6 +387,7 @@ export default class Dev extends Command<typeof Dev> {
366387
url: service.url,
367388
sdl: service.sdl,
368389
})),
390+
target: input.target,
369391
},
370392
},
371393
});

0 commit comments

Comments
 (0)