Skip to content

Commit a20279a

Browse files
fix(composition): Add missing @link validations (#3430)
This PR adds `@link` validations to detect invalid alias names and alias/import conflicts. To maintain backwards compatibility, we do allow some exceptions, and we also auto-generate supergraph spec aliases when conflicts are detected.
1 parent d496957 commit a20279a

13 files changed

Lines changed: 1732 additions & 117 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@apollo/federation-internals": minor
3+
---
4+
5+
Add validations for `@link` usages to prevent name conflicts.
6+
7+
This change helps to avoid ambiguity for downstream `@link`-consuming code, which previously may have found different results for a spec schema element depending on search order. If your composition fails after this change, please rename conflicting elements via `@link(import:)` and conflicting specs/features via `@link(as:)`.
8+
9+
Note that if you were declaring `@link`s for the `https://specs.apollo.dev/tag` or `https://specs.apollo.dev/inaccessible` specs in your subgraph schema, you will need to instead import `@tag` and `@inaccessible` from the `https://specs.apollo.dev/federation` spec. This previous pattern only succeeded due to a now-fixed bug and is fragile/may lead to undesirable behavior.

composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ exports[`composing custom core directives custom tag directive works when federa
44
"schema
55
@link(url: \\"https://specs.apollo.dev/link/v1.0\\")
66
@link(url: \\"https://specs.apollo.dev/join/v0.3\\", for: EXECUTION)
7-
@link(url: \\"https://specs.apollo.dev/tag/v0.3\\", import: [{name: \\"@tag\\", as: \\"@mytag\\"}])
7+
@link(url: \\"https://specs.apollo.dev/tag/v0.3\\", as: \\"_0tag\\", import: [{name: \\"@tag\\", as: \\"@mytag\\"}])
88
@link(url: \\"https://custom.dev/tag/v1.0\\", import: [\\"@tag\\"])
99
{
1010
query: Query

composition-js/src/__tests__/compose.composeDirective.test.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ const expectDirectiveDefinition = (schema: Schema, name: string, locations: Dire
7979
expect(directive?.arguments().map(arg => arg.name)).toEqual(args);
8080
};
8181

82-
const expectCoreFeature = (schema: Schema, identity: string, version: string, imports: { [key: string]: string}[]) => {
82+
const expectCoreFeature = (schema: Schema, identity: string, version: string, imports: { [key: string]: string}[], nameInSchema?: string) => {
8383
const feature = schema.coreFeatures?.getByIdentity(identity);
8484
expect(feature?.url.toString()).toBe(`${identity}/v${version}`);
8585
expect(feature?.imports).toEqual(imports);
86+
expect(feature?.nameInSchema).toEqual(nameInSchema ?? feature?.url?.name);
8687
};
8788

8889
describe('composing custom core directives', () => {
@@ -791,23 +792,23 @@ describe('composing custom core directives', () => {
791792
it('composed directive must be the same original directive in all subgraphs', () => {
792793
const subgraphA = generateSubgraph({
793794
name: 'subgraphA',
794-
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])',
795-
composeText: '@composeDirective(name: "@foo")',
796-
directiveText: 'directive @foo(name: String!) on FIELD_DEFINITION',
797-
usage: '@foo(name: "a")',
795+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@baz"])',
796+
composeText: '@composeDirective(name: "@baz")',
797+
directiveText: 'directive @baz(name: String!) on FIELD_DEFINITION',
798+
usage: '@baz(name: "a")',
798799
});
799800
const subgraphB = generateSubgraph({
800801
name: 'subgraphB',
801-
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@bar", as: "@foo" }])',
802-
composeText: '@composeDirective(name: "@foo")',
803-
directiveText: 'directive @foo(name: String!) on FIELD_DEFINITION',
804-
usage: '@foo(name: "a")',
802+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: [{ name: "@bar", as: "@baz" }])',
803+
composeText: '@composeDirective(name: "@baz")',
804+
directiveText: 'directive @baz(name: String!) on FIELD_DEFINITION',
805+
usage: '@baz(name: "a")',
805806
});
806807

807808
const result = composeServices([subgraphA, subgraphB]);
808809
expect(hints(result)).toEqual([]);
809810
expect(errors(result)).toStrictEqual([
810-
['DIRECTIVE_COMPOSITION_ERROR', `Composed directive "@foo" does not refer to the same directive in every subgraph`],
811+
['DIRECTIVE_COMPOSITION_ERROR', `Composed directive "@baz" does not refer to the same directive in every subgraph`],
811812
]);
812813
});
813814

@@ -874,8 +875,8 @@ describe('composing custom core directives', () => {
874875
const result = composeServices([subgraphA, subgraphB]);
875876
expect(errors(result)).toStrictEqual([
876877
[
877-
'DIRECTIVE_COMPOSITION_ERROR',
878-
`Directive "${directive}" in subgraph "subgraphA" cannot be composed because it is not a member of a core feature`,
878+
'INVALID_LINK_DIRECTIVE_USAGE',
879+
`Cannot import "@foo" as "${directive}" from feature "https://specs.apollo.dev/foo" since it can be confused with a namespaced name from another linked feature "https://specs.apollo.dev/join". Please rename the import or feature to avoid conflicts via "as".`,
879880
]
880881
]);
881882
});
@@ -1004,7 +1005,7 @@ describe('composing custom core directives', () => {
10041005
extend schema
10051006
@link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective", "@tag"])
10061007
@link(url: "https://specs.apollo.dev/link/v1.0")
1007-
@link(url: "https://custom.dev/tag/v1.0", import: [{ name: "@tag", as: "@mytag"}])
1008+
@link(url: "https://custom.dev/tag/v1.0", as: "mytag", import: [{ name: "@tag", as: "@mytag"}])
10081009
@composeDirective(name: "@mytag")
10091010
10101011
directive @mytag(name: String!, prop: String!) on FIELD_DEFINITION | OBJECT
@@ -1045,7 +1046,8 @@ describe('composing custom core directives', () => {
10451046
expectDirectiveDefinition(schema, 'mytag', [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], ['name', 'prop']);
10461047
expectDirectiveOnElement(schema, 'User.a', 'mytag', { name: 'a', prop: 'b' });
10471048
expectDirectiveOnElement(schema, 'User.b', 'tag', { name: 'c' });
1048-
expectCoreFeature(schema, 'https://custom.dev/tag', '1.0', [{ name: '@tag', as: '@mytag' }]);
1049+
expectCoreFeature(schema, 'https://custom.dev/tag', '1.0', [{ name: '@tag', as: '@mytag' }], '_0tag');
1050+
expectCoreFeature(schema, 'https://specs.apollo.dev/tag', '0.3', []);
10491051
});
10501052

10511053
it('custom tag directive works when federation tag is renamed', () => {
@@ -1099,10 +1101,7 @@ describe('composing custom core directives', () => {
10991101
expectDirectiveOnElement(schema, 'User.b', 'mytag', { name: 'c' });
11001102

11011103
expectCoreFeature(schema, 'https://custom.dev/tag', '1.0', [{ name: '@tag' }]);
1102-
const feature = schema.coreFeatures?.getByIdentity('https://specs.apollo.dev/tag');
1103-
expect(feature?.url.toString()).toBe('https://specs.apollo.dev/tag/v0.3');
1104-
expect(feature?.imports).toEqual([{ name: '@tag', as: '@mytag' }]);
1105-
expect(feature?.nameInSchema).toEqual('tag');
1104+
expectCoreFeature(schema, 'https://specs.apollo.dev/tag', '0.3', [{ name: '@tag', as: '@mytag' }], '_0tag');
11061105
expect(printSchema(schema)).toMatchSnapshot();
11071106
});
11081107

composition-js/src/__tests__/composeFed1Subgraphs.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,56 @@ describe('validations', () => {
356356
expect(subgraphCAST.subgraph).toBe("subgraphC");
357357
expect(subgraphCAST.kind).toBe("ObjectTypeExtension");
358358
});
359+
360+
it('errors when trying to use supergraph tag spec', () => {
361+
const subgraphA= {
362+
typeDefs: gql`
363+
schema
364+
@link(url: "https://specs.apollo.dev/link/v1.0")
365+
@link(url: "https://specs.apollo.dev/tag/v0.2")
366+
{
367+
query: Query
368+
}
369+
370+
type Query {
371+
q: Int
372+
}
373+
`,
374+
name: 'subgraphA',
375+
};
376+
377+
const result = composeServices([subgraphA]);
378+
379+
expect(result.errors).toBeDefined();
380+
expect(errors(result)).toStrictEqual([
381+
['INVALID_LINK_DIRECTIVE_USAGE', '[subgraphA] Please import "@tag" from the feature "https://specs.apollo.dev/federation" instead of using "https://specs.apollo.dev/tag" to avoid potential unexpected behavior in the future.'],
382+
]);
383+
});
384+
385+
it('errors when trying to use supergraph inaccessible spec', () => {
386+
const subgraphA= {
387+
typeDefs: gql`
388+
schema
389+
@link(url: "https://specs.apollo.dev/link/v1.0")
390+
@link(url: "https://specs.apollo.dev/inaccessible/v0.2")
391+
{
392+
query: Query
393+
}
394+
395+
type Query {
396+
q: Int
397+
}
398+
`,
399+
name: 'subgraphA',
400+
};
401+
402+
const result = composeServices([subgraphA]);
403+
404+
expect(result.errors).toBeDefined();
405+
expect(errors(result)).toStrictEqual([
406+
['INVALID_LINK_DIRECTIVE_USAGE', '[subgraphA] Please import "@inaccessible" from the feature "https://specs.apollo.dev/federation" instead of using "https://specs.apollo.dev/inaccessible" to avoid potential unexpected behavior in the future.'],
407+
]);
408+
});
359409
});
360410

361411
describe('shareable', () => {

composition-js/src/composeDirectiveManager.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,13 @@ export class ComposeDirectiveManager {
286286
if (featureDetails) {
287287
const identity = featureDetails.feature.url.identity;
288288

289-
// make sure that core feature is not blacklisted
290-
if (DISALLOWED_IDENTITIES.includes(identity)) {
289+
if (featureDetails.nameInFeature === null) {
290+
this.pushError(ERRORS.DIRECTIVE_COMPOSITION_ERROR.err(
291+
`Directive "@${name}" in subgraph "${sg.name}" cannot be composed because it is imported to a different name`,
292+
{ nodes: composeInstance.sourceAST },
293+
));
294+
} else if (DISALLOWED_IDENTITIES.includes(identity)) {
295+
// make sure that core feature is not blacklisted
291296
this.forFederationDirective(sg, composeInstance, directive);
292297
} else if (tagNamesInSubgraphs.includes(name)) {
293298
const subgraphs: string[] = [];

composition-js/src/merging/coreDirectiveCollector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function collectCoreDirectivesToCompose(
2121
const source = features.sourceFeature(directive);
2222
// We ignore directives that are not "core" ones, or the ones that are defined but unused (note that this
2323
// happen to ignore execution directives as a by-product)
24-
if (!source || directive.applications().size === 0) {
24+
if (!source || source.nameInFeature === null || directive.applications().size === 0) {
2525
continue;
2626
}
2727

0 commit comments

Comments
 (0)