diff --git a/.changeset/bright-frogs-float.md b/.changeset/bright-frogs-float.md new file mode 100644 index 00000000..6b43ea73 --- /dev/null +++ b/.changeset/bright-frogs-float.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/stitch': patch +--- + +Fixes the bug when interfaces extended by \`additionalTypeDefs\` diff --git a/.changeset/nice-ducks-smash.md b/.changeset/nice-ducks-smash.md new file mode 100644 index 00000000..609a1634 --- /dev/null +++ b/.changeset/nice-ducks-smash.md @@ -0,0 +1,5 @@ +--- +'@graphql-mesh/fusion-runtime': patch +--- + +Handle \`@resolveTo\` for interfaces correctly diff --git a/e2e/interface-additional-resolvers/interface-additional-resolvers.e2e.ts b/e2e/interface-additional-resolvers/interface-additional-resolvers.e2e.ts new file mode 100644 index 00000000..142bdccf --- /dev/null +++ b/e2e/interface-additional-resolvers/interface-additional-resolvers.e2e.ts @@ -0,0 +1,43 @@ +import { createTenv } from '@internal/e2e'; +import { expect, it } from 'vitest'; + +const { gateway, service } = createTenv(__dirname); + +it('works', async () => { + const { execute } = await gateway({ + supergraph: { + with: 'mesh', + services: [await service('Test')], + }, + pipeLogs: true, + }); + + const result = await execute({ + query: /* GraphQL */ ` + query { + node(id: "1") { + id + ... on User { + name + } + self { + id + ... on User { + name + } + } + } + } + `, + }); + + expect(result.errors).toBeFalsy(); + expect(result.data.node).toEqual({ + id: '1', + name: 'Alice', + self: { + id: '1', + name: 'Alice', + }, + }); +}); diff --git a/e2e/interface-additional-resolvers/mesh.config.ts b/e2e/interface-additional-resolvers/mesh.config.ts new file mode 100644 index 00000000..543e5b1d --- /dev/null +++ b/e2e/interface-additional-resolvers/mesh.config.ts @@ -0,0 +1,33 @@ +import { + defineConfig, + loadGraphQLHTTPSubgraph, +} from '@graphql-mesh/compose-cli'; +import { Opts } from '@internal/testing'; + +const opts = Opts(process.argv); + +export const composeConfig = defineConfig({ + subgraphs: [ + { + sourceHandler: loadGraphQLHTTPSubgraph('Test', { + endpoint: `http://localhost:${opts.getServicePort('Test')}/graphql`, + }), + }, + ], + additionalTypeDefs: /* GraphQL */ ` + extend interface Node { + self: Node! + @resolveTo( + sourceName: "Test" + sourceTypeName: "Query" + sourceFieldName: "node" + sourceArgs: { id: "{root.id}" } + requiredSelectionSet: "{ id }" + ) + } + + extend type User implements Node { + self: Node! + } + `, +}); diff --git a/e2e/interface-additional-resolvers/package.json b/e2e/interface-additional-resolvers/package.json new file mode 100644 index 00000000..a2a2fe82 --- /dev/null +++ b/e2e/interface-additional-resolvers/package.json @@ -0,0 +1,15 @@ +{ + "name": "@e2e/interface-additional-resolvers", + "private": true, + "dependencies": { + "@graphql-mesh/compose-cli": "^1.2.0", + "@graphql-mesh/cross-helpers": "^0.4.8", + "@graphql-mesh/store": "^0.103.4", + "@graphql-mesh/types": "^0.103.4", + "@graphql-mesh/utils": "^0.103.4", + "@graphql-tools/utils": "^10.6.0", + "graphql": "^16.9.0", + "graphql-yoga": "^5.10.4", + "tslib": "^2.8.0" + } +} diff --git a/e2e/interface-additional-resolvers/services/Test.ts b/e2e/interface-additional-resolvers/services/Test.ts new file mode 100644 index 00000000..1a869fd0 --- /dev/null +++ b/e2e/interface-additional-resolvers/services/Test.ts @@ -0,0 +1,40 @@ +import { createServer } from 'node:http'; +import { Opts } from '@internal/testing'; +import { createSchema, createYoga } from 'graphql-yoga'; + +export const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String! + } + + type Query { + node(id: ID!): Node + user(id: ID!): User + } + `, + resolvers: { + Node: { + __resolveType: (obj: { __typename: string }) => obj.__typename, + }, + Query: { + node: () => ({ __typename: 'User', id: '1', name: 'Alice' }), + }, + }, + }), + maskedErrors: false, +}); + +const opts = Opts(process.argv); + +createServer(yoga).listen(opts.getServicePort('Test'), () => { + console.log( + `🚀 Server ready at http://localhost:${opts.getServicePort('Test')}/graphql`, + ); +}); diff --git a/packages/fusion-runtime/src/federation/subgraph.ts b/packages/fusion-runtime/src/federation/subgraph.ts index 5a108647..d922acdf 100644 --- a/packages/fusion-runtime/src/federation/subgraph.ts +++ b/packages/fusion-runtime/src/federation/subgraph.ts @@ -32,6 +32,7 @@ import { GraphQLDirective, GraphQLSchema, GraphQLString, + isInterfaceType, isObjectType, isOutputType, Kind, @@ -327,9 +328,42 @@ export function handleFederationSubschema({ } return undefined; }, - [MapperKind.INTERFACE_FIELD]: (fieldConfig, fieldName, typeName) => { + [MapperKind.INTERFACE_FIELD]: ( + fieldConfig, + fieldName, + typeName, + schema, + ) => { const fieldDirectives = getDirectiveExtensions(fieldConfig); + const resolveToDirectives = fieldDirectives.resolveTo; + if (resolveToDirectives?.length) { + const type = schema.getType(typeName); + if (!isInterfaceType(type)) { + throw new Error( + `Type ${typeName} for field ${fieldName} is not an object type`, + ); + } + const fieldMap = type.getFields(); + const field = fieldMap[fieldName]; + if (!field) { + throw new Error(`Field ${typeName}.${fieldName} not found`); + } + additionalTypeDefs.push({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.INTERFACE_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: typeName }, + fields: [astFromField(field, schema)], + }, + ], + }); + } + const additionalFieldDirectives = fieldDirectives.additionalField; + if (additionalFieldDirectives?.length) { + return null; + } const sourceDirectives = fieldDirectives.source; const sourceDirective = sourceDirectives?.find((directive) => compareSubgraphNames(directive.subgraph, subgraphName), @@ -345,10 +379,6 @@ export function handleFederationSubschema({ } return [realName, fieldConfig]; } - const additionalFieldDirectives = fieldDirectives.additionalField; - if (additionalFieldDirectives?.length) { - return null; - } return undefined; }, [MapperKind.ENUM_VALUE]: ( diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 4f81bc3c..9d37f329 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -202,6 +202,8 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ ); // @ts-expect-error - Typings are wrong opts.resolvers = additionalResolvers; + // @ts-expect-error - Typings are wrong + opts.inheritResolversFromInterfaces = true; if (onDelegationStageExecuteHooks?.length) { for (const subschema of subschemas) { diff --git a/packages/stitch/src/typeCandidates.ts b/packages/stitch/src/typeCandidates.ts index 8fabc801..4df3ec62 100644 --- a/packages/stitch/src/typeCandidates.ts +++ b/packages/stitch/src/typeCandidates.ts @@ -15,12 +15,13 @@ import { import { wrapSchema } from '@graphql-tools/wrap'; import { DocumentNode, - getNamedType, GraphQLDirective, GraphQLNamedType, GraphQLObjectType, GraphQLSchema, isDirective, + isInterfaceType, + isIntrospectionType, isNamedType, isSpecifiedScalarType, OperationTypeNode, @@ -137,7 +138,7 @@ export function buildTypeCandidates< const type = originalTypeMap[typeName] as GraphQLNamedType; if ( isNamedType(type) && - getNamedType(type).name.slice(0, 2) !== '__' && + !isIntrospectionType(type) && !rootTypes.has(type as GraphQLObjectType) ) { addTypeCandidate(typeCandidates, type.name, { @@ -156,6 +157,17 @@ export function buildTypeCandidates< throw new Error(`Expected to get named typed but got ${inspect(def)}`); } if (type != null) { + // There is a bug in interface types that causes them to not have _interfaces + // if they are not used in a schema. This is a workaround for that. + if (isInterfaceType(type)) { + try { + type.getInterfaces(); + } catch { + Object.defineProperty(type, '_interfaces', { + value: [], + }); + } + } addTypeCandidate(typeCandidates, type.name, { type }); } } diff --git a/yarn.lock b/yarn.lock index 0ffa1a36..fa76ea92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2461,6 +2461,22 @@ __metadata: languageName: unknown linkType: soft +"@e2e/interface-additional-resolvers@workspace:e2e/interface-additional-resolvers": + version: 0.0.0-use.local + resolution: "@e2e/interface-additional-resolvers@workspace:e2e/interface-additional-resolvers" + dependencies: + "@graphql-mesh/compose-cli": "npm:^1.2.0" + "@graphql-mesh/cross-helpers": "npm:^0.4.8" + "@graphql-mesh/store": "npm:^0.103.4" + "@graphql-mesh/types": "npm:^0.103.4" + "@graphql-mesh/utils": "npm:^0.103.4" + "@graphql-tools/utils": "npm:^10.6.0" + graphql: "npm:^16.9.0" + graphql-yoga: "npm:^5.10.4" + tslib: "npm:^2.8.0" + languageName: unknown + linkType: soft + "@e2e/js-config@workspace:e2e/js-config": version: 0.0.0-use.local resolution: "@e2e/js-config@workspace:e2e/js-config" @@ -10997,7 +11013,7 @@ __metadata: languageName: node linkType: hard -"graphql-yoga@npm:^5.10.3, graphql-yoga@npm:^5.7.0": +"graphql-yoga@npm:^5.10.3, graphql-yoga@npm:^5.10.4, graphql-yoga@npm:^5.7.0": version: 5.10.4 resolution: "graphql-yoga@npm:5.10.4" dependencies: