Skip to content

Commit

Permalink
Handle unresolvable interface fields and choose the best root field i…
Browse files Browse the repository at this point in the history
…f there are many (#6141)

Update poor-glasses-vanish.md

??

??

Old Node support

Reviews

Point to the example

Better typings instead of casting it

Go

Better comments
  • Loading branch information
ardatan committed May 7, 2024
1 parent a83da08 commit cd962c1
Show file tree
Hide file tree
Showing 7 changed files with 333 additions and 33 deletions.
11 changes: 11 additions & 0 deletions .changeset/poor-glasses-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@graphql-tools/federation": patch
"@graphql-tools/stitch": patch
---

When the gateway receives the query, now it chooses the best root field if there is the same root field in different subgraphs.
For example, if there is `node(id: ID!): Node` in all subgraphs but one implements `User` and the other implements `Post`, the gateway will choose the subgraph that implements `User` or `Post` based on the query.

If there is a unresolvable interface field, it throws.

See [this supergraph and the test query](https://github.com/ardatan/graphql-tools/tree/master/packages/federation/test/fixtures/federation-compatibility/corrupted-supergraph-node-id) to see a real-life example
9 changes: 4 additions & 5 deletions packages/delegate/src/extractUnavailableFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ export const extractUnavailableFieldsFromSelectionSet = memoize4(
? schema.getType(selection.typeCondition.name.value)
: fieldType;
if (
(isObjectType(subFieldType) || isInterfaceType(subFieldType)) &&
isAbstractType(fieldType) &&
schema.isSubType(fieldType, subFieldType)
subFieldType === fieldType ||
((isObjectType(subFieldType) || isInterfaceType(subFieldType)) &&
isAbstractType(fieldType) &&
schema.isSubType(fieldType, subFieldType))
) {
const unavailableFields = extractUnavailableFieldsFromSelectionSet(
schema,
Expand All @@ -110,8 +111,6 @@ export const extractUnavailableFieldsFromSelectionSet = memoize4(
},
});
}
} else {
unavailableSelections.push(selection);
}
}
}
Expand Down
144 changes: 119 additions & 25 deletions packages/federation/src/supergraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,38 @@ import {
InputValueDefinitionNode,
InterfaceTypeDefinitionNode,
InterfaceTypeExtensionNode,
isInterfaceType,
Kind,
NamedTypeNode,
ObjectTypeDefinitionNode,
ObjectTypeExtensionNode,
OperationTypeNode,
parse,
parseType,
print,
ScalarTypeDefinitionNode,
TypeDefinitionNode,
TypeInfo,
UnionTypeDefinitionNode,
visit,
visitWithTypeInfo,
} from 'graphql';
import { MergedTypeConfig, SubschemaConfig } from '@graphql-tools/delegate';
import {
delegateToSchema,
extractUnavailableFields,
MergedTypeConfig,
SubschemaConfig,
} from '@graphql-tools/delegate';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import {
calculateSelectionScore,
getDefaultFieldConfigMerger,
MergeFieldConfigCandidate,
stitchSchemas,
TypeMergingOptions,
ValidationLevel,
} from '@graphql-tools/stitch';
import { memoize1, type Executor } from '@graphql-tools/utils';
import { createGraphQLError, memoize1, type Executor } from '@graphql-tools/utils';
import {
filterInternalFieldsAndTypes,
getArgsFromKeysForFederation,
Expand Down Expand Up @@ -72,6 +82,11 @@ function getTypeFieldMapFromSupergraphAST(supergraphAST: DocumentNode) {
return typeFieldASTMap;
}

const rootTypeMap = new Map<string, OperationTypeNode>([
['Query', 'query' as OperationTypeNode],
['Mutation', 'mutation' as OperationTypeNode],
['Subscription', 'subscription' as OperationTypeNode],
]);
export function getFieldMergerFromSupergraphSdl(
supergraphSdl: DocumentNode | string,
): TypeMergingOptions['fieldConfigMerger'] {
Expand All @@ -81,6 +96,51 @@ export function getFieldMergerFromSupergraphSdl(
const memoizedASTPrint = memoize1(print);
const memoizedTypePrint = memoize1((type: GraphQLOutputType) => type.toString());
return function (candidates: MergeFieldConfigCandidate[]) {
if (
candidates.length === 1 ||
candidates.some(candidate => candidate.fieldName === '_entities')
) {
return candidates[0].fieldConfig;
}
if (candidates.some(candidate => rootTypeMap.has(candidate.type.name))) {
return {
...defaultMerger(candidates),
resolve(_root, _args, context, info) {
let currentSubschema: SubschemaConfig | undefined;
let currentScore = Infinity;
for (const fieldNode of info.fieldNodes) {
const candidatesReversed = candidates.toReversed
? candidates.toReversed()
: [...candidates].reverse();
for (const candidate of candidatesReversed) {
const typeFieldMap = candidate.type.getFields();
if (candidate.transformedSubschema) {
const unavailableFields = extractUnavailableFields(
candidate.transformedSubschema.transformedSchema,
typeFieldMap[candidate.fieldName],
fieldNode,
() => true,
);
const score = calculateSelectionScore(unavailableFields);
if (score < currentScore) {
currentScore = score;
currentSubschema = candidate.transformedSubschema;
}
}
}
}
if (!currentSubschema) {
throw new Error('Could not determine subschema');
}
return delegateToSchema({
schema: currentSubschema,
operation: rootTypeMap.get(info.parentType.name) || ('query' as OperationTypeNode),
context,
info,
});
},
};
}
const filteredCandidates = candidates.filter(candidate => {
const fieldASTMap = typeFieldASTMap.get(candidate.type.name);
if (fieldASTMap) {
Expand Down Expand Up @@ -110,6 +170,10 @@ export function getSubschemasFromSupergraphSdl({
const typeNameCanonicalMap = new Map<string, string>();
const subgraphTypeNameExtraFieldsMap = new Map<string, Map<string, FieldDefinitionNode[]>>();
const orphanTypeMap = new Map<string, TypeDefinitionNode>();

// To detect unresolvable interface fields
const externalFieldMap = new Map<string, Set<string>>();

// TODO: Temporary fix to add missing join__type directives to Query
const subgraphNames: string[] = [];
visit(supergraphAst, {
Expand Down Expand Up @@ -221,6 +285,14 @@ export function getSubschemasFromSupergraphSdl({
argumentNode.value?.kind === Kind.BOOLEAN &&
argumentNode.value.value === true,
);
if (isExternal) {
let externalFields = externalFieldMap.get(graphName);
if (!externalFields) {
externalFields = new Set();
externalFieldMap.set(typeNode.name.value, externalFields);
}
externalFields.add(fieldNode.name.value);
}
if (!isExternal && !isOverridden) {
const typeArg = joinFieldDirectiveNode.arguments?.find(
argumentNode => argumentNode.name.value === 'type',
Expand Down Expand Up @@ -860,34 +932,56 @@ export function getSubschemasFromSupergraphSdl({
delegationContext.args,
fakeTypesIfaceMap,
);
const typeInfo = new TypeInfo(schema);
return {
...request,
document: visit(request.document, {
[Kind.INLINE_FRAGMENT](node) {
if (node.typeCondition) {
const newTypeConditionName = fakeTypesIfaceMap.get(
node.typeCondition?.name.value,
);
if (newTypeConditionName) {
transformationContext.replacedTypes ||= new Map();
transformationContext.replacedTypes.set(
newTypeConditionName,
node.typeCondition.name.value,
document: visit(
request.document,
visitWithTypeInfo(typeInfo, {
// To avoid resolving unresolvable interface fields
[Kind.FIELD](node) {
if (node.name.value !== '__typename') {
const parentType = typeInfo.getParentType();
if (isInterfaceType(parentType)) {
const implementations = schema.getPossibleTypes(parentType);
for (const implementation of implementations) {
const externalFields = externalFieldMap.get(implementation.name);
if (externalFields?.has(node.name.value)) {
throw createGraphQLError(
`Was not able to find any options for ${node.name.value}: This shouldn't have happened.`,
);
}
}
}
}
},
// To resolve interface objects correctly
[Kind.INLINE_FRAGMENT](node) {
if (node.typeCondition) {
const newTypeConditionName = fakeTypesIfaceMap.get(
node.typeCondition?.name.value,
);
return {
...node,
typeCondition: {
kind: Kind.NAMED_TYPE,
name: {
kind: Kind.NAME,
value: newTypeConditionName,
if (newTypeConditionName) {
transformationContext.replacedTypes ||= new Map();
transformationContext.replacedTypes.set(
newTypeConditionName,
node.typeCondition.name.value,
);
return {
...node,
typeCondition: {
kind: Kind.NAMED_TYPE,
name: {
kind: Kind.NAME,
value: newTypeConditionName,
},
},
},
};
};
}
}
}
},
}),
},
}),
),
};
},
transformResult(result, _, transformationContext) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
{
query: Query
}

directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE

directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION

directive @join__graph(name: String!, url: String!) on ENUM_VALUE

directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE

directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR

directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION

directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA

type Account implements Node
@join__implements(graph: A, interface: "Node")
@join__implements(graph: B, interface: "Node")
@join__type(graph: A, key: "id")
@join__type(graph: B, key: "id")
{
id: ID! @join__field(graph: A) @join__field(graph: B, external: true)
username: String! @join__field(graph: A)
chats: [Chat!]! @join__field(graph: B)
}

type Chat implements Node
@join__implements(graph: A, interface: "Node")
@join__implements(graph: B, interface: "Node")
@join__type(graph: A, key: "id")
@join__type(graph: B, key: "id")
{
id: ID! @join__field(graph: A, external: true) @join__field(graph: B)
account: Account! @join__field(graph: A)
text: String! @join__field(graph: B)
}

scalar join__FieldSet

enum join__Graph {
A @join__graph(name: "a", url: "https://federation-compatibility.the-guild.dev/corrupted-supergraph-node-id/a")
B @join__graph(name: "b", url: "https://federation-compatibility.the-guild.dev/corrupted-supergraph-node-id/b")
}

scalar link__Import

enum link__Purpose {
"""
`SECURITY` features provide metadata necessary to securely resolve fields.
"""
SECURITY

"""
`EXECUTION` features provide metadata necessary for operation execution.
"""
EXECUTION
}

interface Node
@join__type(graph: A)
@join__type(graph: B)
{
id: ID!
}

type Query
@join__type(graph: A)
@join__type(graph: B)
{
node(id: ID!): Node
account(id: String!): Account @join__field(graph: A)
chat(id: String!): Chat @join__field(graph: B)
}

0 comments on commit cd962c1

Please sign in to comment.