Skip to content

Commit 5b36fc6

Browse files
fix: relax @interfaceObject validation for Fed 1 subgraphs (#3392)
## Relax `@interfaceObject` validation for Fed 1 subgraphs ### What this PR does Previously, any use of `@interfaceObject` in a Fed 2 subgraph would cause an `INTERFACE_OBJECT_USAGE_ERROR` if **any** Fed 1 subgraph was present in the composition — regardless of whether the types actually conflicted. This was overly restrictive. The real problem is narrower: `@interfaceObject` on type `T` in a Fed 2 subgraph requires other subgraphs to be able to resolve `__typename` via an interface `@key` on `T`. Fed 1 subgraphs silently drop `@key` from interfaces during upgrade (the schema upgrader removes them), so they **cannot** fulfill that requirement for `T` — but they are perfectly fine for any other types. This PR replaces the blanket check with a **per-type** cross-check: - An error is only raised when a Fed 2 subgraph uses `@interfaceObject` on type `T` **and** a Fed 1 subgraph has `@key` on an interface also named `T`. - Fed 2 subgraphs using `@interfaceObject` on a type that has no corresponding interface `@key` in any Fed 1 subgraph compose successfully. ### Changes **`internals-js/src/schemaUpgrader.ts`** - `SchemaUpgrader`: added `interfaceKeyTypes: Set<string>` field to track interface types whose `@key` was removed during upgrade; the set is returned from `upgrade()` on success. - `upgradeSubgraphsIfNecessary`: replaced `subgraphsUsingInterfaceObject` with two `SetMultiMap<string, string>` instances (`fed2InterfaceObjectTypesToSubgraphs` and `fed1InterfaceKeyTypesToSubgraphs`) that are cross-checked per type name after all subgraphs are processed. **`internals-js/src/__tests__/schemaUpgrader.test.ts`** - Updated the existing `@interfaceObject` rejection test with the new, more precise error message. - Added a new test confirming that composition succeeds when the same type name does **not** have a `@key` on a Fed 1 interface. --------- Co-authored-by: Sachin D. Shinde <sachin@apollographql.com>
1 parent e8108c2 commit 5b36fc6

3 files changed

Lines changed: 301 additions & 20 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+
Relax `@interfaceObject` validation for Fed 1 subgraphs
6+
7+
Previously, any use of `@interfaceObject` in a Fed 2 subgraph caused an `INTERFACE_OBJECT_USAGE_ERROR` if any Fed 1 subgraph was present in the composition, regardless of whether the types conflicted.
8+
9+
The check is now per-type: an error is only raised when a Fed 2 subgraph uses `@interfaceObject` on type `T` **and** a Fed 1 subgraph has `@key` on an interface also named `T`. `@key` on an interface in a Fed 1 subgraph does not mean it can fulfill the `__typename`-resolution requirement that `@interfaceObject` depends on — but they are otherwise compatible with `@interfaceObject` usage on unrelated types.

internals-js/src/__tests__/schemaUpgrader.test.ts

Lines changed: 272 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,11 @@ test('remove tag on external field if found on definition', () => {
235235
).toStrictEqual(['@tag(name: "a tag")']);
236236
});
237237

238-
test('reject @interfaceObject usage if not all subgraphs are fed2', () => {
239-
// Note that this test both validates the rejection of fed1 subgraph when @interfaceObject is used somewhere, but also
240-
// illustrate why we do so: fed1 schema can use @key on interface for backward compatibility, but it is ignored and
241-
// the schema upgrader removes them. Given that actual support for @key on interfaces is necesarry to make @interfaceObject
242-
// work, it would be really confusing to not reject the example below right away, since it "looks" like it the @key on
243-
// the interface in the 2nd subgraph should work, but it actually won't.
238+
test('reject @interfaceObject usage when a fed1 subgraph has @key on the same interface type', () => {
239+
// This test validates that when a fed2 subgraph uses @interfaceObject on a type, and a fed1 subgraph
240+
// has @key on an interface of the same name, we produce an error. @key on an interface in a Fed 1
241+
// subgraph does not indicate it can resolve type names for that interface via _entities, which is
242+
// required for @interfaceObject to work correctly.
244243

245244
const s1 = `
246245
extend schema
@@ -273,8 +272,273 @@ test('reject @interfaceObject usage if not all subgraphs are fed2', () => {
273272
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
274273
const res = upgradeSubgraphsIfNecessary(subgraphs);
275274
expect(res.errors?.map((e) => e.message)).toStrictEqual([
276-
'The @interfaceObject directive can only be used if all subgraphs have federation 2 subgraph schema (schema with a `@link` to "https://specs.apollo.dev/federation" version 2.0 or newer): ' +
277-
'@interfaceObject is used in subgraph "s1" but subgraph "s2" is not a federation 2 subgraph schema.',
275+
'The @interfaceObject directive is used on type "A" in subgraph "s1", which requires other subgraphs to resolve its type name via an interface @key. However, @key on an interface in a federation 1 subgraph does not mean it can fulfill the __typename-resolution requirement that @interfaceObject depends on. For subgraph "s2", either upgrade them to federation 2 subgraphs or remove @key from the type.',
276+
]);
277+
});
278+
279+
test('allow @interfaceObject in fed2 subgraph when no fed1 subgraph has @key on the same interface type', () => {
280+
// When a fed2 subgraph uses @interfaceObject on a type but no fed1 subgraph has @key on an interface
281+
// of the same name, composition should succeed. The fed1 subgraph may define an interface type with
282+
// the same name, but since it has no @interfaceObject-incompatible interface @key, no error is expected.
283+
284+
const s1 = `
285+
extend schema
286+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
287+
288+
type Query {
289+
a: A
290+
}
291+
292+
type A @key(fields: "id") @interfaceObject {
293+
id: String
294+
x: Int
295+
}
296+
`;
297+
298+
// s2 is a fed1 subgraph that defines A as an interface but does NOT put @key on the interface itself.
299+
const s2 = `
300+
interface A {
301+
id: String
302+
y: Int
303+
}
304+
305+
type X implements A @key(fields: "id") {
306+
id: String
307+
y: Int
308+
}
309+
`;
310+
311+
const subgraphs = new Subgraphs();
312+
subgraphs.add(buildSubgraph('s1', 'http://s1', s1));
313+
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
314+
const res = upgradeSubgraphsIfNecessary(subgraphs);
315+
expect(res.errors).toBeUndefined();
316+
});
317+
318+
test('allow @interfaceObject usage when all subgraphs are fed2', () => {
319+
// When all subgraphs are fed2, the upgrader is never invoked, so @interfaceObject
320+
// combined with @key on an interface in another fed2 subgraph is perfectly valid.
321+
const s1 = `
322+
extend schema
323+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
324+
325+
type Query {
326+
a: A
327+
}
328+
329+
type A @key(fields: "id") @interfaceObject {
330+
id: String
331+
x: Int
332+
}
333+
`;
334+
335+
// s2 is also a fed2 subgraph with @key on interface A.
336+
const s2 = `
337+
extend schema
338+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key"])
339+
340+
interface A @key(fields: "id") {
341+
id: String
342+
y: Int
343+
}
344+
345+
type X implements A @key(fields: "id") {
346+
id: String
347+
y: Int
348+
}
349+
`;
350+
351+
const subgraphs = new Subgraphs();
352+
subgraphs.add(buildSubgraph('s1', 'http://s1', s1));
353+
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
354+
const res = upgradeSubgraphsIfNecessary(subgraphs);
355+
// Should NOT error: all subgraphs are fed2, no upgrader involved.
356+
expect(res.errors).toBeUndefined();
357+
});
358+
359+
test('allow @key on interface in fed1 subgraph when no fed2 subgraph uses @interfaceObject on that type', () => {
360+
// A fed1 subgraph with @key on an interface is fine on its own — the key is simply
361+
// stripped during upgrade. No error should be raised when no fed2 subgraph uses
362+
// @interfaceObject on that same type name.
363+
const s1 = `
364+
extend schema
365+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
366+
367+
type Query {
368+
b: B
369+
}
370+
371+
type B @key(fields: "id") @interfaceObject {
372+
id: String
373+
x: Int
374+
}
375+
`;
376+
377+
// s2 is a fed1 subgraph with @key on interface A — but no fed2 subgraph uses
378+
// @interfaceObject on type A.
379+
const s2 = `
380+
interface A @key(fields: "id") {
381+
id: String
382+
y: Int
383+
}
384+
385+
type X implements A @key(fields: "id") {
386+
id: String
387+
y: Int
388+
}
389+
`;
390+
391+
const subgraphs = new Subgraphs();
392+
subgraphs.add(buildSubgraph('s1', 'http://s1', s1));
393+
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
394+
const res = upgradeSubgraphsIfNecessary(subgraphs);
395+
// Should NOT error: no fed2 subgraph uses @interfaceObject on type A.
396+
expect(res.errors).toBeUndefined();
397+
});
398+
399+
test('reports separate errors for each conflicting type', () => {
400+
// When a fed2 subgraph uses @interfaceObject on two different types (A and B), and a
401+
// fed1 subgraph has @key on both interface A and interface B, two separate errors must
402+
// be emitted — one per conflicting type.
403+
const s1 = `
404+
extend schema
405+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
406+
407+
type Query {
408+
a: A
409+
}
410+
411+
type A @key(fields: "id") @interfaceObject {
412+
id: String
413+
}
414+
415+
type B @key(fields: "id") @interfaceObject {
416+
id: String
417+
}
418+
`;
419+
420+
const s2 = `
421+
interface A @key(fields: "id") {
422+
id: String
423+
}
424+
425+
type XA implements A @key(fields: "id") {
426+
id: String
427+
}
428+
429+
interface B @key(fields: "id") {
430+
id: String
431+
}
432+
433+
type XB implements B @key(fields: "id") {
434+
id: String
435+
}
436+
`;
437+
438+
const subgraphs = new Subgraphs();
439+
subgraphs.add(buildSubgraph('s1', 'http://s1', s1));
440+
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
441+
const res = upgradeSubgraphsIfNecessary(subgraphs);
442+
expect(res.errors).toHaveLength(2);
443+
expect(res.errors?.map((e) => e.message)).toEqual(
444+
expect.arrayContaining([
445+
expect.stringContaining('type "A"'),
446+
expect.stringContaining('type "B"'),
447+
]),
448+
);
449+
});
450+
451+
test('error message includes all fed2 subgraphs using @interfaceObject on the same type', () => {
452+
// When multiple fed2 subgraphs all use @interfaceObject on the same type, all their
453+
// names must appear in the error message.
454+
const s1 = `
455+
extend schema
456+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
457+
458+
type Query {
459+
a: A
460+
}
461+
462+
type A @key(fields: "id") @interfaceObject {
463+
id: String
464+
x: Int
465+
}
466+
`;
467+
468+
const s2 = `
469+
extend schema
470+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
471+
472+
type A @key(fields: "id") @interfaceObject {
473+
id: String
474+
y: Int
475+
}
476+
`;
477+
478+
const s3 = `
479+
interface A @key(fields: "id") {
480+
id: String
481+
}
482+
483+
type X implements A @key(fields: "id") {
484+
id: String
485+
}
486+
`;
487+
488+
const subgraphs = new Subgraphs();
489+
subgraphs.add(buildSubgraph('s1', 'http://s1', s1));
490+
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
491+
subgraphs.add(buildSubgraph('s3', 'http://s3', s3));
492+
const res = upgradeSubgraphsIfNecessary(subgraphs);
493+
expect(res.errors?.map((e) => e.message)).toStrictEqual([
494+
'The @interfaceObject directive is used on type "A" in subgraphs "s1" and "s2", which requires other subgraphs to resolve its type name via an interface @key. However, @key on an interface in a federation 1 subgraph does not mean it can fulfill the __typename-resolution requirement that @interfaceObject depends on. For subgraph "s3", either upgrade them to federation 2 subgraphs or remove @key from the type.',
495+
]);
496+
});
497+
498+
test('error message includes all fed1 subgraphs with @key on the same interface type', () => {
499+
// When multiple fed1 subgraphs all have @key on the same interface type, all their
500+
// names must appear in the error message (the "For ..." part).
501+
const s1 = `
502+
extend schema
503+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: [ "@key", "@interfaceObject"])
504+
505+
type Query {
506+
a: A
507+
}
508+
509+
type A @key(fields: "id") @interfaceObject {
510+
id: String
511+
x: Int
512+
}
513+
`;
514+
515+
const s2 = `
516+
interface A @key(fields: "id") {
517+
id: String
518+
}
519+
520+
type X implements A @key(fields: "id") {
521+
id: String
522+
}
523+
`;
524+
525+
const s3 = `
526+
interface A @key(fields: "id") {
527+
id: String
528+
}
529+
530+
type Y implements A @key(fields: "id") {
531+
id: String
532+
}
533+
`;
534+
535+
const subgraphs = new Subgraphs();
536+
subgraphs.add(buildSubgraph('s1', 'http://s1', s1));
537+
subgraphs.add(buildSubgraph('s2', 'http://s2', s2));
538+
subgraphs.add(buildSubgraph('s3', 'http://s3', s3));
539+
const res = upgradeSubgraphsIfNecessary(subgraphs);
540+
expect(res.errors?.map((e) => e.message)).toStrictEqual([
541+
'The @interfaceObject directive is used on type "A" in subgraph "s1", which requires other subgraphs to resolve its type name via an interface @key. However, @key on an interface in a federation 1 subgraph does not mean it can fulfill the __typename-resolution requirement that @interfaceObject depends on. For subgraphs "s2" and "s3", either upgrade them to federation 2 subgraphs or remove @key from the type.',
278542
]);
279543
});
280544

internals-js/src/schemaUpgrader.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
Subgraph,
3333
Subgraphs,
3434
} from "./federation";
35-
import { assert, firstOf, MultiMap } from "./utils";
35+
import { assert, firstOf, MultiMap, SetMultiMap } from "./utils";
3636
import { valueEquals } from "./values";
3737
import { FEDERATION1_TYPES } from "./specs/federationSpec";
3838

@@ -230,7 +230,8 @@ export function upgradeSubgraphsIfNecessary(inputs: Subgraphs): UpgradeResult {
230230

231231
const subgraphs = new Subgraphs();
232232
let errors: GraphQLError[] = [];
233-
const subgraphsUsingInterfaceObject = [];
233+
const fed2InterfaceObjectTypesToSubgraphs = new SetMultiMap<string, string>();
234+
const fed1InterfaceKeyTypesToSubgraphs = new SetMultiMap<string, string>();
234235

235236
// build a data structure to help us do computation only once
236237
const objectTypeMap = new Map<string, Map<string, [ObjectType | InterfaceType, FederationMetadata]>>();
@@ -256,8 +257,9 @@ export function upgradeSubgraphsIfNecessary(inputs: Subgraphs): UpgradeResult {
256257
for (const subgraph of inputs.values()) {
257258
if (subgraph.isFed2Subgraph()) {
258259
subgraphs.add(subgraph);
259-
if (subgraph.metadata().interfaceObjectDirective().applications().size > 0) {
260-
subgraphsUsingInterfaceObject.push(subgraph.name);
260+
for (const application of subgraph.metadata().interfaceObjectDirective().applications()) {
261+
const typeName = (application.parent as NamedType).name;
262+
fed2InterfaceObjectTypesToSubgraphs.add(typeName, subgraph.name);
261263
}
262264
} else {
263265
const res = new SchemaUpgrader(subgraph, inputs.values(), objectTypeMap).upgrade();
@@ -266,16 +268,19 @@ export function upgradeSubgraphsIfNecessary(inputs: Subgraphs): UpgradeResult {
266268
} else {
267269
subgraphs.add(res.upgraded);
268270
changes.set(subgraph.name, res.changes);
271+
for (const typeName of res.interfaceKeyTypes) {
272+
fed1InterfaceKeyTypesToSubgraphs.add(typeName, subgraph.name);
273+
}
269274
}
270275
}
271276
}
272-
if (errors.length === 0 && subgraphsUsingInterfaceObject.length > 0) {
273-
const fed1Subgraphs = inputs.values().filter((s) => !s.isFed2Subgraph()).map((s) => s.name);
274-
// Note that we exit this method early if everything is a fed2 schema, so we know at least one of them wasn't.
275-
errors = [ ERRORS.INTERFACE_OBJECT_USAGE_ERROR.err(
276-
'The @interfaceObject directive can only be used if all subgraphs have federation 2 subgraph schema (schema with a `@link` to "https://specs.apollo.dev/federation" version 2.0 or newer): '
277-
+ `@interfaceObject is used in ${printSubgraphNames(subgraphsUsingInterfaceObject)} but ${printSubgraphNames(fed1Subgraphs)} ${fed1Subgraphs.length > 1 ? 'are not' : 'is not a'} federation 2 subgraph schema.`,
278-
)];
277+
for (const [typeName, interfaceObjectSubgraphs] of fed2InterfaceObjectTypesToSubgraphs) {
278+
const interfaceKeySubgraphs = fed1InterfaceKeyTypesToSubgraphs.get(typeName);
279+
if (interfaceKeySubgraphs) {
280+
errors.push(ERRORS.INTERFACE_OBJECT_USAGE_ERROR.err(
281+
`The @interfaceObject directive is used on type "${typeName}" in ${printSubgraphNames([...interfaceObjectSubgraphs])}, which requires other subgraphs to resolve its type name via an interface @key. However, @key on an interface in a federation 1 subgraph does not mean it can fulfill the __typename-resolution requirement that @interfaceObject depends on. For ${printSubgraphNames([...interfaceKeySubgraphs])}, either upgrade them to federation 2 subgraphs or remove @key from the type.`,
282+
));
283+
}
279284
}
280285

281286
return errors.length === 0 ? { subgraphs, changes } : { errors };
@@ -324,6 +329,7 @@ class SchemaUpgrader {
324329
private readonly subgraph: Subgraph;
325330
private readonly metadata: FederationMetadata;
326331
private readonly errors: GraphQLError[] = [];
332+
private readonly interfaceKeyTypes: Set<string> = new Set();
327333

328334
constructor(private readonly originalSubgraph: Subgraph, private readonly allSubgraphs: readonly Subgraph[], private readonly objectTypeMap: Map<string, Map<string, [ObjectType | InterfaceType, FederationMetadata]>>) {
329335
// Note that as we clone the original schema, the 'sourceAST' values in the elements of the new schema will be those of the original schema
@@ -413,7 +419,7 @@ class SchemaUpgrader {
413419
}
414420
}
415421

416-
upgrade(): { upgraded: Subgraph, changes: UpgradeChanges, errors?: never } | { errors: GraphQLError[] } {
422+
upgrade(): { upgraded: Subgraph, changes: UpgradeChanges, interfaceKeyTypes: Set<string>, errors?: never } | { errors: GraphQLError[] } {
417423
this.preUpgradeValidations();
418424

419425
this.fixFederationDirectivesArguments();
@@ -452,6 +458,7 @@ class SchemaUpgrader {
452458
return {
453459
upgraded: this.subgraph,
454460
changes: this.changes,
461+
interfaceKeyTypes: this.interfaceKeyTypes,
455462
};
456463
} catch (e) {
457464
const errors = errorCauses(e);
@@ -679,6 +686,7 @@ class SchemaUpgrader {
679686
for (const type of this.schema.interfaceTypes()) {
680687
for (const application of type.appliedDirectivesOf(this.metadata.keyDirective())) {
681688
this.addChange(new KeyOnInterfaceRemoval(type.name));
689+
this.interfaceKeyTypes.add(type.name);
682690
application.remove();
683691
}
684692
for (const field of type.fields()) {

0 commit comments

Comments
 (0)