Skip to content

Commit 4435820

Browse files
authored
fix(schema): building contracts with extend keyword (#6706)
1 parent 099d799 commit 4435820

File tree

4 files changed

+225
-56
lines changed

4 files changed

+225
-56
lines changed

.changeset/wicked-flowers-bake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hive': patch
3+
---
4+
5+
Improve contract schema building for subgraphs using the `extend` keyword.

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,90 @@ test('type is marked as inaccessible if all fields are inaccessible and the type
392392
'type Brr @join__type(graph: FOO_GRAPHQL) @inaccessible {',
393393
);
394394
});
395+
396+
test('inaccessible is only applied once per type', async () => {
397+
const result = await client.composeAndValidate.mutate({
398+
type: 'federation',
399+
native: true,
400+
schemas: [
401+
{
402+
raw: /* GraphQL */ `
403+
extend schema
404+
@link(url: "https://specs.apollo.dev/link/v1.0")
405+
@link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@tag"])
406+
407+
type Query {
408+
hello: String @tag(name: "public")
409+
}
410+
411+
type Brr {
412+
a: String
413+
}
414+
415+
extend type Brr {
416+
b: String
417+
}
418+
`,
419+
source: 'foo.graphql',
420+
url: null,
421+
},
422+
],
423+
external: null,
424+
contracts: [
425+
{
426+
id: 'foo',
427+
filter: {
428+
removeUnreachableTypesFromPublicApiSchema: true,
429+
exclude: null,
430+
include: ['public'],
431+
},
432+
},
433+
],
434+
});
435+
436+
expect(result.contracts?.[0].errors).toEqual([]);
437+
});
438+
439+
test('inaccessible is not applied on type if at least one type extension has a public field', async () => {
440+
const result = await client.composeAndValidate.mutate({
441+
type: 'federation',
442+
native: true,
443+
schemas: [
444+
{
445+
raw: /* GraphQL */ `
446+
extend schema
447+
@link(url: "https://specs.apollo.dev/link/v1.0")
448+
@link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@tag"])
449+
450+
type Query {
451+
hello: String @tag(name: "public")
452+
}
453+
454+
type Brr {
455+
a: String
456+
}
457+
458+
extend type Brr {
459+
b: String @tag(name: "public")
460+
}
461+
`,
462+
source: 'foo.graphql',
463+
url: null,
464+
},
465+
],
466+
external: null,
467+
contracts: [
468+
{
469+
id: 'foo',
470+
filter: {
471+
removeUnreachableTypesFromPublicApiSchema: false,
472+
exclude: null,
473+
include: ['public'],
474+
},
475+
},
476+
],
477+
});
478+
479+
expect(result.contracts?.[0].errors).toEqual([]);
480+
expect(result.contracts?.[0].sdl).toContain('type Brr {');
481+
});

packages/services/schema/src/lib/federation-tag-extraction.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1818,7 +1818,7 @@ describe('applyTagFilterOnSubgraphs', () => {
18181818
field1: String! @inaccessible
18191819
}
18201820
1821-
extend type Type1 @inaccessible {
1821+
extend type Type1 {
18221822
field2: String! @inaccessible
18231823
}
18241824
`);

packages/services/schema/src/lib/federation-tag-extraction.ts

Lines changed: 132 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ function getRootTypeNamesFromDocumentNode(document: DocumentNode) {
9393
return names;
9494
}
9595

96+
type ObjectLikeNode =
97+
| ObjectTypeExtensionNode
98+
| ObjectTypeDefinitionNode
99+
| InterfaceTypeDefinitionNode
100+
| InterfaceTypeExtensionNode
101+
| InputObjectTypeDefinitionNode
102+
| InputObjectTypeExtensionNode;
103+
96104
/**
97105
* Takes a subgraph document node and a set of tag filters and transforms the document node to contain `@inaccessible` directives on all fields not included by the applied filter.
98106
* Note: you probably want to use `filterSubgraphs` instead, as it also applies the correct post step required after applying this.
@@ -102,8 +110,11 @@ export function applyTagFilterToInaccessibleTransformOnSubgraphSchema(
102110
filter: Federation2SubgraphDocumentNodeByTagsFilter,
103111
): {
104112
typeDefs: DocumentNode;
113+
/** Types within THIS subgraph where all fields are inaccessible */
105114
typesWithAllFieldsInaccessible: Map<string, boolean>;
106115
transformTagDirectives: ReturnType<typeof createTransformTagDirectives>;
116+
/** Types in this subgraph that have the inaccessible directive applied */
117+
typesWithInaccessibleApplied: Set<string>;
107118
} {
108119
const { resolveImportName } = extractLinkImplementations(documentNode);
109120
const inaccessibleDirectiveName = resolveImportName(
@@ -149,71 +160,119 @@ export function applyTagFilterToInaccessibleTransformOnSubgraphSchema(
149160
};
150161
}
151162

152-
function fieldLikeObjectHandler(
153-
node:
154-
| ObjectTypeExtensionNode
155-
| ObjectTypeDefinitionNode
156-
| InterfaceTypeDefinitionNode
157-
| InterfaceTypeExtensionNode
158-
| InputObjectTypeDefinitionNode
159-
| InputObjectTypeExtensionNode,
160-
) {
161-
const tagsOnNode = getTagsOnNode(node);
162-
163-
let isAllFieldsInaccessible = true;
164-
165-
const newNode = {
166-
...node,
167-
fields: node.fields?.map(node => {
168-
const tagsOnNode = getTagsOnNode(node);
169-
170-
if (node.kind === Kind.FIELD_DEFINITION) {
171-
node = {
172-
...node,
173-
arguments: node.arguments?.map(fieldArgumentHandler),
174-
} as FieldDefinitionNode;
163+
const definitionsBySchemaCoordinate = new Map<string, Array<ObjectLikeNode>>();
164+
165+
//
166+
// A type can be defined multiple times within a subgraph and we need to find all implementations
167+
// for determining whether the full type, or only some fields are part of the public contract schema
168+
//
169+
for (const definition of documentNode.definitions) {
170+
switch (definition.kind) {
171+
case Kind.OBJECT_TYPE_DEFINITION:
172+
case Kind.OBJECT_TYPE_EXTENSION:
173+
case Kind.INTERFACE_TYPE_DEFINITION:
174+
case Kind.INTERFACE_TYPE_EXTENSION:
175+
case Kind.INPUT_OBJECT_TYPE_DEFINITION:
176+
case Kind.INPUT_OBJECT_TYPE_EXTENSION: {
177+
let items = definitionsBySchemaCoordinate.get(definition.name.value);
178+
if (!items) {
179+
items = [];
180+
definitionsBySchemaCoordinate.set(definition.name.value, items);
175181
}
182+
items.push(definition);
183+
}
184+
}
185+
}
186+
187+
// Tracking for which type already has `@inaccessible` applied (can only occur once)
188+
const typesWithInaccessibleApplied = new Set<string>();
189+
// These are later used within the visitor to actually replace the nodes.
190+
const replacementTypeNodes = new Map<ObjectLikeNode, ObjectLikeNode>();
191+
192+
for (const [typeName, nodes] of definitionsBySchemaCoordinate) {
193+
/** After processing all nodes implementing a type, we know whether all or only some fields are inaccessible */
194+
let isSomeFieldsAccessible = false;
195+
/** First node occurance record as stored within the `replacementTypeNodes` map. */
196+
let firstReplacementTypeNodeRecord: {
197+
key: ObjectLikeNode;
198+
value: ObjectLikeNode;
199+
} | null = null;
200+
201+
for (const node of nodes) {
202+
const tagsOnNode = getTagsOnNode(node);
203+
let newNode = {
204+
...node,
205+
fields: node.fields?.map(node => {
206+
const tagsOnNode = getTagsOnNode(node);
207+
208+
if (node.kind === Kind.FIELD_DEFINITION) {
209+
node = {
210+
...node,
211+
arguments: node.arguments?.map(fieldArgumentHandler),
212+
} as FieldDefinitionNode;
213+
}
214+
215+
if (
216+
(filter.include.size && !hasIntersection(tagsOnNode, filter.include)) ||
217+
(filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude))
218+
) {
219+
return {
220+
...node,
221+
directives: transformTagDirectives(node, true),
222+
};
223+
}
224+
225+
isSomeFieldsAccessible = true;
176226

177-
if (
178-
(filter.include.size && !hasIntersection(tagsOnNode, filter.include)) ||
179-
(filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude))
180-
) {
181227
return {
182228
...node,
183-
directives: transformTagDirectives(node, true),
229+
directives: transformTagDirectives(node),
184230
};
185-
}
186-
187-
isAllFieldsInaccessible = false;
188-
189-
return {
190-
...node,
231+
}),
232+
} as ObjectLikeNode;
233+
234+
if (filter.exclude.size && hasIntersection(tagsOnNode, filter.exclude)) {
235+
newNode = {
236+
...newNode,
237+
directives: transformTagDirectives(
238+
node,
239+
typesWithInaccessibleApplied.has(typeName) ? false : true,
240+
),
241+
} as ObjectLikeNode;
242+
typesWithInaccessibleApplied.add(typeName);
243+
} else {
244+
newNode = {
245+
...newNode,
191246
directives: transformTagDirectives(node),
192247
};
193-
}),
194-
};
248+
}
195249

196-
if (
197-
!rootTypeNames.has(node.name.value) &&
198-
filter.exclude.size &&
199-
hasIntersection(tagsOnNode, filter.exclude)
200-
) {
201-
return {
202-
...newNode,
203-
directives: transformTagDirectives(node, true),
204-
};
250+
if (!firstReplacementTypeNodeRecord) {
251+
firstReplacementTypeNodeRecord = {
252+
key: node,
253+
value: newNode,
254+
};
255+
}
256+
replacementTypeNodes.set(node, newNode);
205257
}
206258

207-
if (isAllFieldsInaccessible) {
208-
onAllFieldsInaccessible(node.name.value);
209-
} else {
210-
onSomeFieldsAccessible(node.name.value);
259+
// If some fields are accessible, we continue with the next type
260+
if (isSomeFieldsAccessible) {
261+
onSomeFieldsAccessible(typeName);
262+
continue;
211263
}
264+
onAllFieldsInaccessible(typeName);
265+
}
212266

213-
return {
214-
...newNode,
215-
directives: transformTagDirectives(node),
216-
};
267+
function fieldLikeObjectHandler(node: ObjectLikeNode) {
268+
const newNode = replacementTypeNodes.get(node);
269+
if (!newNode) {
270+
throw new Error(
271+
`Found type without transformation mapping. ${node.name.value} ${node.name.kind}`,
272+
);
273+
}
274+
275+
return newNode;
217276
}
218277

219278
function enumHandler(node: EnumTypeDefinitionNode | EnumTypeExtensionNode) {
@@ -304,6 +363,7 @@ export function applyTagFilterToInaccessibleTransformOnSubgraphSchema(
304363
typeDefs,
305364
typesWithAllFieldsInaccessible: typesWithAllFieldsInaccessibleTracker,
306365
transformTagDirectives,
366+
typesWithInaccessibleApplied,
307367
};
308368
}
309369

@@ -312,6 +372,8 @@ function makeTypesFromSetInaccessible(
312372
types: Set<string>,
313373
transformTagDirectives: ReturnType<typeof createTransformTagDirectives>,
314374
) {
375+
/** We can only apply @accessible once on each unique typename, otherwise we get a composition error */
376+
const alreadyAppliedOnTypeNames = new Set<string>();
315377
function typeHandler(
316378
node:
317379
| ObjectTypeExtensionNode
@@ -323,9 +385,10 @@ function makeTypesFromSetInaccessible(
323385
| EnumTypeDefinitionNode
324386
| EnumTypeExtensionNode,
325387
) {
326-
if (types.has(node.name.value) === false) {
388+
if (types.has(node.name.value) === false || alreadyAppliedOnTypeNames.has(node.name.value)) {
327389
return;
328390
}
391+
alreadyAppliedOnTypeNames.add(node.name.value);
329392
return {
330393
...node,
331394
directives: transformTagDirectives(node, true),
@@ -392,12 +455,26 @@ export function applyTagFilterOnSubgraphs<
392455
...subgraph,
393456
typeDefs: makeTypesFromSetInaccessible(
394457
subgraph.typeDefs,
395-
intersectionOfTypesWhereAllFieldsAreInaccessible,
458+
/** We exclude the types that are already marked as inaccessible within the subgraph as we want to avoid `@inaccessible` applied more than once. */
459+
difference(
460+
intersectionOfTypesWhereAllFieldsAreInaccessible,
461+
subgraph.typesWithInaccessibleApplied,
462+
),
396463
subgraph.transformTagDirectives,
397464
),
398465
}));
399466
}
400467

468+
function difference<$Type>(set1: Set<$Type>, set2: Set<$Type>): Set<$Type> {
469+
const result = new Set<$Type>();
470+
set1.forEach(item => {
471+
if (!set2.has(item)) {
472+
result.add(item);
473+
}
474+
});
475+
return result;
476+
}
477+
401478
export const extractTagsFromDocument = (
402479
documentNode: DocumentNode,
403480
tagStrategy: TagExtractionStrategy,

0 commit comments

Comments
 (0)