From 8618e065c0d7a64b4303fa9dfe1892abfc115629 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 16:50:29 +0200 Subject: [PATCH 01/44] bigan --- e2e/relay-additional-resolvers/package.json | 9 +++ .../relay-additional-resolvers.e2e.ts | 31 ++++++++++ .../services/posts.ts | 56 +++++++++++++++++++ .../services/users.ts | 54 ++++++++++++++++++ yarn.lock | 10 ++++ 5 files changed, 160 insertions(+) create mode 100644 e2e/relay-additional-resolvers/package.json create mode 100644 e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts create mode 100644 e2e/relay-additional-resolvers/services/posts.ts create mode 100644 e2e/relay-additional-resolvers/services/users.ts diff --git a/e2e/relay-additional-resolvers/package.json b/e2e/relay-additional-resolvers/package.json new file mode 100644 index 000000000..e5b4dfb9e --- /dev/null +++ b/e2e/relay-additional-resolvers/package.json @@ -0,0 +1,9 @@ +{ + "name": "@e2e/relay-additional-resolvers", + "private": true, + "dependencies": { + "@apollo/subgraph": "^2.10.0", + "graphql": "^16.9.0", + "graphql-sse": "^2.5.3" + } +} diff --git a/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts b/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts new file mode 100644 index 000000000..70d377164 --- /dev/null +++ b/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts @@ -0,0 +1,31 @@ +import { createTenv } from '@internal/e2e'; +import { expect, it } from 'vitest'; + +const { gateway, service } = createTenv(__dirname); + +it('should resolve the data behind the node', async () => { + const { execute } = await gateway({ + supergraph: { + with: 'apollo', + services: [await service('users'), await service('posts')], + }, + }); + + await expect( + execute({ + query: /* GraphQL */ ` + { + node(id: "user-2") { + ... on User { + name + posts { + title + content + } + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(); +}); diff --git a/e2e/relay-additional-resolvers/services/posts.ts b/e2e/relay-additional-resolvers/services/posts.ts new file mode 100644 index 000000000..fd3557053 --- /dev/null +++ b/e2e/relay-additional-resolvers/services/posts.ts @@ -0,0 +1,56 @@ +import { createServer } from 'http'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { Opts } from '@internal/testing'; +import { parse } from 'graphql'; +import { createYoga } from 'graphql-yoga'; + +const posts: { id: string; title: string; content: string }[] = [ + { + id: 'post-1', + title: 'Hello world', + content: 'This is a post', + }, + { + id: 'post-2', + title: 'Hello again', + content: 'This is another post', + }, + { + id: 'post-3', + title: 'Hello again again', + content: 'This is another post again', + }, +]; + +const typeDefs = parse(/* GraphQL */ ` + type Query { + hello: String! + } + interface Node { + id: ID! + } + type Post implements Node @key(fields: "id") { + id: ID! + title: String! + content: String! + } +`); + +const resolvers = { + Query: { + hello: () => 'world', + }, + Post: { + __resolveReference(post: { id: string }) { + return posts.find((p) => p.id === post.id); + }, + }, +}; + +const yoga = createYoga({ + schema: buildSubgraphSchema([{ typeDefs, resolvers }]), +}); + +const opts = Opts(process.argv); + +createServer(yoga).listen(opts.getServicePort('posts')); diff --git a/e2e/relay-additional-resolvers/services/users.ts b/e2e/relay-additional-resolvers/services/users.ts new file mode 100644 index 000000000..dbcf214ff --- /dev/null +++ b/e2e/relay-additional-resolvers/services/users.ts @@ -0,0 +1,54 @@ +import { createServer } from 'http'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { Opts } from '@internal/testing'; +import { parse } from 'graphql'; +import { createYoga } from 'graphql-yoga'; + +const users: { id: string; name: string; posts: { id: string }[] }[] = [ + { + id: 'user-1', + name: 'John Doe', + posts: [{ id: 'post-2' }], + }, + { + id: 'user-2', + name: 'Jane Doe', + posts: [{ id: 'post-3' }, { id: 'post-1' }], + }, +]; + +const typeDefs = parse(/* GraphQL */ ` + type Query { + hello: String! + } + interface Node { + id: ID! + } + type User implements Node @key(fields: "id") { + id: ID! + name: String! + posts: [Post!]! + } + type Post implements Node { + id: ID! + } +`); + +const resolvers = { + Query: { + hello: () => 'world', + }, + User: { + __resolveReference(user: { id: string }) { + return users.find((u) => u.id === user.id); + }, + }, +}; + +const yoga = createYoga({ + schema: buildSubgraphSchema([{ typeDefs, resolvers }]), +}); + +const opts = Opts(process.argv); + +createServer(yoga).listen(opts.getServicePort('users')); diff --git a/yarn.lock b/yarn.lock index bef19b367..b072395f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3356,6 +3356,16 @@ __metadata: languageName: unknown linkType: soft +"@e2e/relay-additional-resolvers@workspace:e2e/relay-additional-resolvers": + version: 0.0.0-use.local + resolution: "@e2e/relay-additional-resolvers@workspace:e2e/relay-additional-resolvers" + dependencies: + "@apollo/subgraph": "npm:^2.10.0" + graphql: "npm:^16.9.0" + graphql-sse: "npm:^2.5.3" + languageName: unknown + linkType: soft + "@e2e/retry-timeout@workspace:e2e/retry-timeout": version: 0.0.0-use.local resolution: "@e2e/retry-timeout@workspace:e2e/retry-timeout" From 9871737e949a8656b36c01feb7c8d478a71a08e1 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 19:02:47 +0200 Subject: [PATCH 02/44] begin --- .../gateway.config.ts | 43 +++++ e2e/relay-additional-resolvers/gw.out | 173 ++++++++++++++++++ e2e/relay-additional-resolvers/id.ts | 20 ++ .../relay-additional-resolvers.e2e.ts | 28 ++- 4 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 e2e/relay-additional-resolvers/gateway.config.ts create mode 100644 e2e/relay-additional-resolvers/gw.out create mode 100644 e2e/relay-additional-resolvers/id.ts diff --git a/e2e/relay-additional-resolvers/gateway.config.ts b/e2e/relay-additional-resolvers/gateway.config.ts new file mode 100644 index 000000000..669a03551 --- /dev/null +++ b/e2e/relay-additional-resolvers/gateway.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from '@graphql-hive/gateway'; +import { decodeGlobalID } from './id'; + +export const gatewayConfig = defineConfig({ + additionalTypeDefs: /* GraphQL */ ` + type Query { + node(id: ID!): Node + nodes(ids: [ID!]!): [Node]! + } + `, + additionalResolvers: { + Query: { + node(_source: any, args: { id: string }, _context: any, _info: any) { + const { type, localID } = decodeGlobalID(args.id); + return { + __typename: type, + id: localID, + }; + }, + nodes(_source: any, args: { ids: string[] }, _context: any, _info: any) { + return args.ids.map((id) => { + const { type, localID } = decodeGlobalID(id); + return { + __typename: type, + id: localID, + }; + }); + }, + _entities: (_: any, { representations }: any) => { + return representations.map((ref) => { + // Since all our entities just need id, we can return the reference as is + // The __typename is already included in the reference + return ref; + }); + }, + }, + Node: { + __resolveType(source: { __typename: string }) { + return source.__typename; + }, + }, + }, +}); diff --git a/e2e/relay-additional-resolvers/gw.out b/e2e/relay-additional-resolvers/gw.out new file mode 100644 index 000000000..16295a523 --- /dev/null +++ b/e2e/relay-additional-resolvers/gw.out @@ -0,0 +1,173 @@ +[2025-05-26T14:55:36.564Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql  +[2025-05-26T14:55:36.566Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql  +[2025-05-26T14:55:36.566Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql for changes  +[2025-05-26T14:55:36.569Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql  +[2025-05-26T14:55:36.579Z] INFO  Listening on http://localhost:60575  +[2025-05-26T14:56:42.380Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T14:56:42.387Z] INFO  Loaded config  +[2025-05-26T14:56:42.387Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql  +[2025-05-26T14:56:42.388Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql  +[2025-05-26T14:56:42.388Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql for changes  +[2025-05-26T14:56:42.390Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql  +[2025-05-26T14:56:42.397Z] INFO  Listening on http://localhost:60714  +[2025-05-26T14:56:42.610Z] ERROR  Error: Query.node defined in resolvers, but not in schema + at addResolversToSchema (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/schema/esm/addResolversToSchema.js:80:39) + at stitchSchemas (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/stitch/src/stitchSchemas.ts:136:12) + at getStitchedSchemaFromSupergraphSdl (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/federation/src/supergraph.ts:1524:26) + at UnifiedGraphManager.handleFederationSupergraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/federation/supergraph.ts:188:32) + at UnifiedGraphManager.handleLoadedUnifiedGraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:302:16) + at (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:388:14)  +[2025-05-26T14:56:42.614Z] ERROR  Error: Query.node defined in resolvers, but not in schema + at addResolversToSchema (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/schema/esm/addResolversToSchema.js:80:39) + at stitchSchemas (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/stitch/src/stitchSchemas.ts:136:12) + at getStitchedSchemaFromSupergraphSdl (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/federation/src/supergraph.ts:1524:26) + at UnifiedGraphManager.handleFederationSupergraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/federation/supergraph.ts:188:32) + at UnifiedGraphManager.handleLoadedUnifiedGraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:302:16) + at (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:388:14)  +[2025-05-26T14:57:52.746Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T14:57:52.753Z] INFO  Loaded config  +[2025-05-26T14:57:52.753Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql  +[2025-05-26T14:57:52.754Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql  +[2025-05-26T14:57:52.754Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql for changes  +[2025-05-26T14:57:52.756Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql  +[2025-05-26T14:57:52.762Z] INFO  Listening on http://localhost:60826  +[2025-05-26T14:57:53.026Z] ERROR  Error: Cannot query field "node" on type "Query". + at Object.node (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts:13:15) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:351:24) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) + at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:37 + at Promise.then (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:34:40) + at handleMaybePromise (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:10:33) + at executeImpl (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:12) + at execute (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:49:12) + at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/normalizedExecutor.js:13:37 { + path: [ 'node' ], + locations: [ { line: 3, column: 11 } ], + extensions: [Object: null prototype] {} +}  +[2025-05-26T15:01:11.667Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:01:11.674Z] INFO  Loaded config  +[2025-05-26T15:01:11.674Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql  +[2025-05-26T15:01:11.675Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql  +[2025-05-26T15:01:11.675Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql for changes  +[2025-05-26T15:01:11.678Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql  +[2025-05-26T15:01:11.685Z] INFO  Listening on http://localhost:61090  +[2025-05-26T15:02:08.930Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:02:08.939Z] INFO  Loaded config  +[2025-05-26T15:02:08.939Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql  +[2025-05-26T15:02:08.940Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql  +[2025-05-26T15:02:08.940Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql for changes  +[2025-05-26T15:02:08.942Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql  +[2025-05-26T15:02:08.948Z] INFO  Listening on http://localhost:61194  +[2025-05-26T15:02:30.927Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:02:30.935Z] INFO  Loaded config  +[2025-05-26T15:02:30.935Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql  +[2025-05-26T15:02:30.936Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql  +[2025-05-26T15:02:30.936Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql for changes  +[2025-05-26T15:02:30.938Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql  +[2025-05-26T15:02:30.944Z] INFO  Listening on http://localhost:61257  +{ id: 'VXNlcjp1c2VyLTI=' } +[2025-05-26T15:02:31.187Z] ERROR  Error: Cannot query field "node" on type "Query". + at Object.node (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts:14:15) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:351:24) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) + at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:37 + at Promise.then (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:34:40) + at handleMaybePromise (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:10:33) + at executeImpl (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:12) + at execute (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:49:12) + at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/normalizedExecutor.js:13:37 { + path: [ 'node' ], + locations: [ { line: 3, column: 11 } ], + extensions: [Object: null prototype] {} +}  +[2025-05-26T15:04:08.181Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:04:08.191Z] INFO  Loaded config  +[2025-05-26T15:04:08.191Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql  +[2025-05-26T15:04:08.191Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql  +[2025-05-26T15:04:08.191Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql for changes  +[2025-05-26T15:04:08.194Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql  +[2025-05-26T15:04:08.200Z] INFO  Listening on http://localhost:61412  +[2025-05-26T15:04:08.458Z] ERROR  Error: Cannot return null for non-nullable field User.name. + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) + at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) + at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { + path: [ 'node', 'name' ], + locations: [ { line: 5, column: 15 } ], + extensions: [Object: null prototype] {} +}  +[2025-05-26T15:05:46.728Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:05:46.738Z] INFO  Loaded config  +[2025-05-26T15:05:46.738Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql  +[2025-05-26T15:05:46.739Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql  +[2025-05-26T15:05:46.739Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql for changes  +[2025-05-26T15:05:46.741Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql  +[2025-05-26T15:05:46.747Z] INFO  Listening on http://localhost:61572  +[2025-05-26T15:05:47.016Z] ERROR  Error: Cannot return null for non-nullable field User.name. + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) + at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) + at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { + path: [ 'node', 'name' ], + locations: [ { line: 5, column: 15 } ], + extensions: [Object: null prototype] {} +}  +[2025-05-26T15:09:42.499Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:09:42.508Z] INFO  Loaded config  +[2025-05-26T15:09:42.508Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql  +[2025-05-26T15:09:42.509Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql  +[2025-05-26T15:09:42.509Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql for changes  +[2025-05-26T15:09:42.511Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql  +[2025-05-26T15:09:42.517Z] INFO  Listening on http://localhost:61887  +[2025-05-26T15:09:42.786Z] ERROR  Error: Cannot return null for non-nullable field User.name. + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) + at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) + at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { + path: [ 'node', 'name' ], + locations: [ { line: 5, column: 15 } ], + extensions: [Object: null prototype] {} +}  +[2025-05-26T15:12:09.330Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  +[2025-05-26T15:12:09.342Z] INFO  Loaded config  +[2025-05-26T15:12:09.343Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql  +[2025-05-26T15:12:09.343Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql  +[2025-05-26T15:12:09.343Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql for changes  +[2025-05-26T15:12:09.346Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql  +[2025-05-26T15:12:09.352Z] INFO  Listening on http://localhost:62107  +[2025-05-26T15:12:09.626Z] ERROR  Error: Cannot return null for non-nullable field User.name. + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) + at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) + at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) + at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) + at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) + at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) + at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { + path: [ 'node', 'name' ], + locations: [ { line: 5, column: 15 } ], + extensions: [Object: null prototype] {} +}  diff --git a/e2e/relay-additional-resolvers/id.ts b/e2e/relay-additional-resolvers/id.ts new file mode 100644 index 000000000..79c91eb09 --- /dev/null +++ b/e2e/relay-additional-resolvers/id.ts @@ -0,0 +1,20 @@ +/** + * Encodes a type and local ID into a global ID + * @param type - The type of the entity (e.g., "Person", "Story") + * @param localID - The local ID of the entity + * @returns Base64 encoded global ID + */ +export function encodeGlobalID(type: string, localID: string) { + return Buffer.from(JSON.stringify([type, localID])).toString('base64'); +} + +/** + * Decodes a global ID into its type and local ID components + * @param globalID - The base64 encoded global ID + * @returns Object containing type and local ID + */ +export function decodeGlobalID(globalID: string) { + const decoded = Buffer.from(globalID, 'base64').toString('utf-8'); + const [type, localID] = JSON.parse(decoded) as [string, string]; + return { type, localID }; +} diff --git a/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts b/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts index 70d377164..b9e90fb2e 100644 --- a/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts +++ b/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts @@ -1,10 +1,12 @@ import { createTenv } from '@internal/e2e'; import { expect, it } from 'vitest'; +import { encodeGlobalID } from './id'; const { gateway, service } = createTenv(__dirname); it('should resolve the data behind the node', async () => { const { execute } = await gateway({ + pipeLogs: 'gw.out', supergraph: { with: 'apollo', services: [await service('users'), await service('posts')], @@ -14,8 +16,8 @@ it('should resolve the data behind the node', async () => { await expect( execute({ query: /* GraphQL */ ` - { - node(id: "user-2") { + query ($id: ID!) { + node(id: $id) { ... on User { name posts { @@ -26,6 +28,26 @@ it('should resolve the data behind the node', async () => { } } `, + variables: { + id: encodeGlobalID('User', 'user-2'), + }, }), - ).resolves.toMatchInlineSnapshot(); + ).resolves.toMatchInlineSnapshot(` + { + "errors": [ + { + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + }, + "locations": [ + { + "column": 11, + "line": 3, + }, + ], + "message": "Cannot query field "node" on type "Query".", + }, + ], + } + `); }); From 1380a588b9314c89912b9ecc502ddb92a8c32ac4 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 9 Jun 2025 16:06:47 +0200 Subject: [PATCH 03/44] start setting up tests --- packages/federation/src/supergraph.ts | 5 ++ .../getStitchedSchemaFromLocalSchemas.ts | 3 ++ packages/federation/tests/relay.test.ts | 51 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 packages/federation/tests/relay.test.ts diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 55ef24126..578c3b39a 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -129,6 +129,11 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { * Configure the batch delegation options for all merged types in all subschemas. */ batchDelegateOptions?: MergedTypeConfig['dataLoaderOptions']; + /** + * Add support for Relay's spec of Node interface aka. + * Global Object Identification. + */ + relayObjectIdentification?: boolean; } export function getStitchingOptionsFromSupergraphSdl( diff --git a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts index ac65bc445..a0aa602db 100644 --- a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts +++ b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts @@ -21,6 +21,7 @@ export async function getStitchedSchemaFromLocalSchemas({ onSubgraphExecute, composeWith = 'apollo', ignoreRules, + relayObjectIdentification, }: { localSchemas: Record; onSubgraphExecute?: ( @@ -30,6 +31,7 @@ export async function getStitchedSchemaFromLocalSchemas({ ) => void; composeWith?: 'apollo' | 'guild'; ignoreRules?: string[]; + relayObjectIdentification?: boolean; }): Promise { let supergraphSdl: string; if (composeWith === 'apollo') { @@ -73,6 +75,7 @@ export async function getStitchedSchemaFromLocalSchemas({ } return getStitchedSchemaFromSupergraphSdl({ supergraphSdl, + relayObjectIdentification, onSubschemaConfig(subschemaConfig) { const [name, localSchema] = Object.entries(localSchemas).find( diff --git a/packages/federation/tests/relay.test.ts b/packages/federation/tests/relay.test.ts new file mode 100644 index 000000000..47aa6e350 --- /dev/null +++ b/packages/federation/tests/relay.test.ts @@ -0,0 +1,51 @@ +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { normalizedExecutor } from '@graphql-tools/executor'; +import { parse } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; + +describe('Relay Object Identification', () => { + it('should resolve node by id', async () => { + const accounts = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Person @key(fields: "id") { + id: ID! + name: String! + email: String! + } + `), + resolvers: { + Person: { + __resolveReference: (ref) => ({ + id: ref.id, + name: 'John Doe', + email: 'john@doe.com', + }), + }, + }, + }); + + const supergraph = await getStitchedSchemaFromLocalSchemas({ + localSchemas: { + accounts, + }, + }); + + await expect( + normalizedExecutor({ + schema: supergraph, + document: parse(/* GraphQL */ ` + query ($id: ID!) { + node(id: $id) { + ... on Person { + id + name + email + } + } + } + `), + }), + ).resolves.toMatchInlineSnapshot(); + }); +}); From bb9ad3ae60038058bb60785115ba3a94b177de4d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 9 Jun 2025 16:20:11 +0200 Subject: [PATCH 04/44] lets try node froid --- packages/federation/package.json | 2 + .../getStitchedSchemaFromLocalSchemas.ts | 47 +++++++++++++++---- packages/federation/tests/relay.test.ts | 23 +++++---- yarn.lock | 23 ++++++++- 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/packages/federation/package.json b/packages/federation/package.json index 617f445b1..5cc5bfc69 100644 --- a/packages/federation/package.json +++ b/packages/federation/package.json @@ -47,10 +47,12 @@ "@graphql-tools/utils": "^10.8.1", "@graphql-tools/wrap": "workspace:^", "@graphql-yoga/typed-event-target": "^3.0.1", + "@wayfair/node-froid": "^3.2.2", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/events": "^0.1.2", "@whatwg-node/fetch": "^0.10.8", "@whatwg-node/promise-helpers": "^1.3.0", + "graphql-relay": "^0.10.2", "tslib": "^2.8.1" }, "devDependencies": { diff --git a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts index a0aa602db..70fe6815e 100644 --- a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts +++ b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts @@ -1,13 +1,17 @@ import { createDefaultExecutor } from '@graphql-tools/delegate'; +import { executorFromSchema } from '@graphql-tools/executor'; import { ExecutionRequest, ExecutionResult, getDocumentNodeFromSchema, } from '@graphql-tools/utils'; -import { composeLocalSchemasWithApollo } from '@internal/testing'; +import { + assertSingleExecutionValue, + composeLocalSchemasWithApollo, +} from '@internal/testing'; import { composeServices } from '@theguild/federation-composition'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; -import { GraphQLSchema } from 'graphql'; +import { GraphQLSchema, parse } from 'graphql'; import { kebabCase } from 'lodash'; import { getStitchedSchemaFromSupergraphSdl } from '../src/supergraph'; @@ -16,13 +20,7 @@ export interface LocalSchemaItem { schema: GraphQLSchema; } -export async function getStitchedSchemaFromLocalSchemas({ - localSchemas, - onSubgraphExecute, - composeWith = 'apollo', - ignoreRules, - relayObjectIdentification, -}: { +export interface StitchedSchemaFromLocalSchemasOptions { localSchemas: Record; onSubgraphExecute?: ( subgraph: string, @@ -32,7 +30,15 @@ export async function getStitchedSchemaFromLocalSchemas({ composeWith?: 'apollo' | 'guild'; ignoreRules?: string[]; relayObjectIdentification?: boolean; -}): Promise { +} + +export async function getStitchedSchemaFromLocalSchemas({ + localSchemas, + onSubgraphExecute, + composeWith = 'apollo', + ignoreRules, + relayObjectIdentification, +}: StitchedSchemaFromLocalSchemasOptions): Promise { let supergraphSdl: string; if (composeWith === 'apollo') { supergraphSdl = await composeLocalSchemasWithApollo( @@ -89,3 +95,24 @@ export async function getStitchedSchemaFromLocalSchemas({ }, }); } + +export async function stitchLocalSchemas( + opts: StitchedSchemaFromLocalSchemasOptions, +) { + const schema = await getStitchedSchemaFromLocalSchemas(opts); + const executor = executorFromSchema(schema); + return { + schema, + async execute({ + query, + variables, + }: { + query: string; + variables?: Record; + }) { + const result = await executor({ document: parse(query), variables }); + assertSingleExecutionValue(result); + return result; + }, + }; +} diff --git a/packages/federation/tests/relay.test.ts b/packages/federation/tests/relay.test.ts index 47aa6e350..c5b18ddaa 100644 --- a/packages/federation/tests/relay.test.ts +++ b/packages/federation/tests/relay.test.ts @@ -1,13 +1,15 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; -import { normalizedExecutor } from '@graphql-tools/executor'; import { parse } from 'graphql'; import { describe, expect, it } from 'vitest'; -import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; +import { stitchLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; describe('Relay Object Identification', () => { it('should resolve node by id', async () => { const accounts = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` + type Query { + people: [Person!]! + } type Person @key(fields: "id") { id: ID! name: String! @@ -25,16 +27,16 @@ describe('Relay Object Identification', () => { }, }); - const supergraph = await getStitchedSchemaFromLocalSchemas({ + const { execute } = await stitchLocalSchemas({ + relayObjectIdentification: true, localSchemas: { accounts, }, }); await expect( - normalizedExecutor({ - schema: supergraph, - document: parse(/* GraphQL */ ` + execute({ + query: /* GraphQL */ ` query ($id: ID!) { node(id: $id) { ... on Person { @@ -44,8 +46,13 @@ describe('Relay Object Identification', () => { } } } - `), + `, + variables: { id: '1' }, }), - ).resolves.toMatchInlineSnapshot(); + ).resolves.toMatchInlineSnapshot(` + { + "data": {}, + } + `); }); }); diff --git a/yarn.lock b/yarn.lock index b072395f1..e6ca113fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -397,7 +397,7 @@ __metadata: languageName: node linkType: hard -"@apollo/subgraph@npm:^2.11.0": +"@apollo/subgraph@npm:^2.10.0, @apollo/subgraph@npm:^2.11.0": version: 2.11.0 resolution: "@apollo/subgraph@npm:2.11.0" dependencies: @@ -5090,12 +5090,14 @@ __metadata: "@graphql-tools/wrap": "workspace:^" "@graphql-yoga/typed-event-target": "npm:^3.0.1" "@types/lodash": "npm:4.17.18" + "@wayfair/node-froid": "npm:^3.2.2" "@whatwg-node/disposablestack": "npm:^0.0.6" "@whatwg-node/events": "npm:^0.1.2" "@whatwg-node/fetch": "npm:^0.10.8" "@whatwg-node/promise-helpers": "npm:^1.3.0" graphql: "npm:^16.9.0" graphql-federation-gateway-audit: "the-guild-org/graphql-federation-gateway-audit#main" + graphql-relay: "npm:^0.10.2" pkgroll: "npm:2.13.1" tslib: "npm:^2.8.1" peerDependencies: @@ -9264,6 +9266,16 @@ __metadata: languageName: node linkType: hard +"@wayfair/node-froid@npm:^3.2.2": + version: 3.2.2 + resolution: "@wayfair/node-froid@npm:3.2.2" + peerDependencies: + graphql: ^16 + graphql-relay: ^0.10 + checksum: 10c0/e3cf2762757308896e2a0965b466905eacf5215c0c257df3695a815279c9186dfce56882ca95363d315a552711a426c6b35c6564be0c60e3f2eb3fe12d929543 + languageName: node + linkType: hard + "@whatwg-node/cookie-store@npm:^0.2.0, @whatwg-node/cookie-store@npm:^0.2.2": version: 0.2.3 resolution: "@whatwg-node/cookie-store@npm:0.2.3" @@ -14285,6 +14297,15 @@ __metadata: languageName: node linkType: hard +"graphql-relay@npm:^0.10.2": + version: 0.10.2 + resolution: "graphql-relay@npm:0.10.2" + peerDependencies: + graphql: ^16.2.0 + checksum: 10c0/312748377c699c812541551cb2079308be6efef99e5398928dbbeca7596581d1fd8d939f60b2c77d184a185b64717e8c2b62b102a8f85167d379fd49cad39a3d + languageName: node + linkType: hard + "graphql-scalars@npm:^1.22.4, graphql-scalars@npm:^1.23.0": version: 1.24.2 resolution: "graphql-scalars@npm:1.24.2" From 09b9ce67c5c3f81da5b84f7515ce04f9f6e8af64 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 9 Jun 2025 18:38:46 +0200 Subject: [PATCH 05/44] global object ident --- packages/federation/src/supergraph.ts | 34 +++++++++++++++++-- .../getStitchedSchemaFromLocalSchemas.ts | 6 ++-- ...s => global-object-identification.test.ts} | 2 +- 3 files changed, 35 insertions(+), 7 deletions(-) rename packages/federation/tests/{relay.test.ts => global-object-identification.test.ts} (97%) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 578c3b39a..26ee47a81 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -130,10 +130,38 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { */ batchDelegateOptions?: MergedTypeConfig['dataLoaderOptions']; /** - * Add support for Relay's spec of Node interface aka. - * Global Object Identification. + * Add support for GraphQL Global Object Identification Specification by adding a `Node` + * interface, `node(id: ID!): Node` and `nodes(ids: [ID!]!): [Node!]!` fields to the `Query` type. + * + * The `Node` interface will have a `nodeId` (not `id`!) field used as the global identifier. It + * is intentionally not `id` to avoid collisions with existing `id` fields in subgraphs. + * + * ```graphql + * """An object with a globally unique `ID`.""" + * interface Node { + * """ + * A globally unique identifier. Can be used in various places throughout the system to identify this single value. + * """ + * nodeId: ID! + * } + * + * extend type Query { + * """Fetches an object given its globally unique `ID`.""" + * node( + * """The globally unique `ID`.""" + * nodeId: ID! + * ): Node + * """Fetches objects given their globally unique `ID`s.""" + * nodes( + * """The globally unique `ID`s.""" + * nodeIds: [ID!]! + * ): [Node!]! + * } + * ``` + * + * @see https://relay.dev/graphql/objectidentification.htm */ - relayObjectIdentification?: boolean; + globalObjectIdentification?: boolean; } export function getStitchingOptionsFromSupergraphSdl( diff --git a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts index 70fe6815e..b9cdd60e7 100644 --- a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts +++ b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts @@ -29,7 +29,7 @@ export interface StitchedSchemaFromLocalSchemasOptions { ) => void; composeWith?: 'apollo' | 'guild'; ignoreRules?: string[]; - relayObjectIdentification?: boolean; + globalObjectIdentification?: boolean; } export async function getStitchedSchemaFromLocalSchemas({ @@ -37,7 +37,7 @@ export async function getStitchedSchemaFromLocalSchemas({ onSubgraphExecute, composeWith = 'apollo', ignoreRules, - relayObjectIdentification, + globalObjectIdentification, }: StitchedSchemaFromLocalSchemasOptions): Promise { let supergraphSdl: string; if (composeWith === 'apollo') { @@ -81,7 +81,7 @@ export async function getStitchedSchemaFromLocalSchemas({ } return getStitchedSchemaFromSupergraphSdl({ supergraphSdl, - relayObjectIdentification, + globalObjectIdentification, onSubschemaConfig(subschemaConfig) { const [name, localSchema] = Object.entries(localSchemas).find( diff --git a/packages/federation/tests/relay.test.ts b/packages/federation/tests/global-object-identification.test.ts similarity index 97% rename from packages/federation/tests/relay.test.ts rename to packages/federation/tests/global-object-identification.test.ts index c5b18ddaa..0996472a1 100644 --- a/packages/federation/tests/relay.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -28,7 +28,7 @@ describe('Relay Object Identification', () => { }); const { execute } = await stitchLocalSchemas({ - relayObjectIdentification: true, + globalObjectIdentification: true, localSchemas: { accounts, }, From 696e55a9836ea747c99eb72968fdbc6dbc1dfd64 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 9 Jun 2025 20:08:53 +0200 Subject: [PATCH 06/44] revert getstitched --- .../getStitchedSchemaFromLocalSchemas.ts | 49 +++++-------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts index b9cdd60e7..8f39045d4 100644 --- a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts +++ b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts @@ -1,17 +1,13 @@ import { createDefaultExecutor } from '@graphql-tools/delegate'; -import { executorFromSchema } from '@graphql-tools/executor'; import { ExecutionRequest, ExecutionResult, getDocumentNodeFromSchema, } from '@graphql-tools/utils'; -import { - assertSingleExecutionValue, - composeLocalSchemasWithApollo, -} from '@internal/testing'; +import { composeLocalSchemasWithApollo } from '@internal/testing'; import { composeServices } from '@theguild/federation-composition'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; -import { GraphQLSchema, parse } from 'graphql'; +import { GraphQLSchema } from 'graphql'; import { kebabCase } from 'lodash'; import { getStitchedSchemaFromSupergraphSdl } from '../src/supergraph'; @@ -20,7 +16,13 @@ export interface LocalSchemaItem { schema: GraphQLSchema; } -export interface StitchedSchemaFromLocalSchemasOptions { +export async function getStitchedSchemaFromLocalSchemas({ + localSchemas, + onSubgraphExecute, + composeWith = 'apollo', + ignoreRules, + globalObjectIdentification, +}: { localSchemas: Record; onSubgraphExecute?: ( subgraph: string, @@ -30,15 +32,7 @@ export interface StitchedSchemaFromLocalSchemasOptions { composeWith?: 'apollo' | 'guild'; ignoreRules?: string[]; globalObjectIdentification?: boolean; -} - -export async function getStitchedSchemaFromLocalSchemas({ - localSchemas, - onSubgraphExecute, - composeWith = 'apollo', - ignoreRules, - globalObjectIdentification, -}: StitchedSchemaFromLocalSchemasOptions): Promise { +}): Promise { let supergraphSdl: string; if (composeWith === 'apollo') { supergraphSdl = await composeLocalSchemasWithApollo( @@ -89,30 +83,9 @@ export async function getStitchedSchemaFromLocalSchemas({ ) || []; if (name && localSchema) { subschemaConfig.executor = createTracedExecutor(name, localSchema); - } else { + } else if (!globalObjectIdentification) { throw new Error(`Unknown subgraph ${subschemaConfig.name}`); } }, }); } - -export async function stitchLocalSchemas( - opts: StitchedSchemaFromLocalSchemasOptions, -) { - const schema = await getStitchedSchemaFromLocalSchemas(opts); - const executor = executorFromSchema(schema); - return { - schema, - async execute({ - query, - variables, - }: { - query: string; - variables?: Record; - }) { - const result = await executor({ document: parse(query), variables }); - assertSingleExecutionValue(result); - return result; - }, - }; -} From 4ca1d97719d9009f609965d29f80474dbc7848d2 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 9 Jun 2025 20:08:58 +0200 Subject: [PATCH 07/44] begin --- packages/federation/src/supergraph.ts | 43 +++++++++++++++ .../global-object-identification.test.ts | 54 +++++++++++-------- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 26ee47a81..0f5524ef1 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -16,6 +16,7 @@ import { buildHTTPExecutor, HTTPExecutorOptions, } from '@graphql-tools/executor-http'; +import { makeExecutableSchema } from '@graphql-tools/schema'; import { calculateSelectionScore, getDefaultFieldConfigMerger, @@ -1252,6 +1253,48 @@ export function getStitchingOptionsFromSupergraphSdl( subschemas.push(subschemaConfig); } + if (opts.globalObjectIdentification && typeNameKeysBySubgraphMap.size) { + const nodeIdField = 'nodeId'; + const typeDefs = ` + type Query { + """Fetches an object given its globally unique \`ID\`.""" + node( + """The globally unique \`ID\`.""" + ${nodeIdField}: ID! + ): Node + } + interface Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + ${nodeIdField}: ID! + } + ${typeNameKeysBySubgraphMap + .values() + .flatMap((type) => + type.keys().map( + (typeName) => ` + type ${typeName} implements Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + ${nodeIdField}: ID! + }`, + ), + ) + .toArray() + .join('\n')} + `; + const globalObjectIdentSubschema: SubschemaConfig = { + name: 'global-object-identification', + schema: makeExecutableSchema({ + typeDefs, + // TODO: resolvers + }), + }; + subschemas.push(globalObjectIdentSubschema); + } + const defaultMerger = getDefaultFieldConfigMerger(true); const fieldConfigMerger: TypeMergingOptions['fieldConfigMerger'] = function ( candidates: MergeFieldConfigCandidate[], diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index 0996472a1..dd9030868 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -1,9 +1,10 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { parse } from 'graphql'; import { describe, expect, it } from 'vitest'; -import { stitchLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; +import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; -describe('Relay Object Identification', () => { +describe('Global Object Identification', () => { it('should resolve node by id', async () => { const accounts = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` @@ -27,32 +28,43 @@ describe('Relay Object Identification', () => { }, }); - const { execute } = await stitchLocalSchemas({ + const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { accounts, }, }); - await expect( - execute({ - query: /* GraphQL */ ` - query ($id: ID!) { - node(id: $id) { - ... on Person { - id - name - email - } - } - } - `, - variables: { id: '1' }, - }), - ).resolves.toMatchInlineSnapshot(` - { - "data": {}, + expect(printSchemaWithDirectives(schema)).toMatchInlineSnapshot(` + "schema { + query: Query } + + type Query { + people: [Person!]! + """Fetches an object given its globally unique \`ID\`.""" + node( + """The globally unique \`ID\`.""" + nodeId: ID! + ): Node + } + + type Person implements Node { + id: ID! + name: String! + email: String! + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + } + + interface Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + }" `); }); }); From 992a6d7476cb208901ba0a1a4b88bc94f418a359 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 9 Jun 2025 20:32:56 +0200 Subject: [PATCH 08/44] schema with node --- packages/federation/package.json | 1 - packages/federation/src/supergraph.ts | 20 +++++++- .../global-object-identification.test.ts | 50 +++++++++++-------- yarn.lock | 11 ---- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/packages/federation/package.json b/packages/federation/package.json index 5cc5bfc69..9febfa147 100644 --- a/packages/federation/package.json +++ b/packages/federation/package.json @@ -47,7 +47,6 @@ "@graphql-tools/utils": "^10.8.1", "@graphql-tools/wrap": "workspace:^", "@graphql-yoga/typed-event-target": "^3.0.1", - "@wayfair/node-froid": "^3.2.2", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/events": "^0.1.2", "@whatwg-node/fetch": "^0.10.8", diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 0f5524ef1..9ba2ccac5 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -68,6 +68,7 @@ import { visit, visitWithTypeInfo, } from 'graphql'; +import { fromGlobalId } from 'graphql-relay'; import { filterInternalFieldsAndTypes, getArgsFromKeysForFederation, @@ -1289,7 +1290,24 @@ export function getStitchingOptionsFromSupergraphSdl( name: 'global-object-identification', schema: makeExecutableSchema({ typeDefs, - // TODO: resolvers + resolvers: { + Query: { + node: (_source, args: { [nodeIdField]: string }) => { + const id = args[nodeIdField]; + if (!fromGlobalId(args[nodeIdField]).type) { + return null; + } + return { + [nodeIdField]: id, + }; + }, + }, + Node: { + __resolveType: (source: { [nodeIdField]: string }) => { + return fromGlobalId(source[nodeIdField]).type; + }, + }, + }, }), }; subschemas.push(globalObjectIdentSubschema); diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index dd9030868..ada9288ad 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -1,33 +1,41 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; +import { normalizedExecutor } from '@graphql-tools/executor'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { parse } from 'graphql'; +import { toGlobalId } from 'graphql-relay'; import { describe, expect, it } from 'vitest'; import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; describe('Global Object Identification', () => { - it('should resolve node by id', async () => { - const accounts = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - people: [Person!]! - } - type Person @key(fields: "id") { - id: ID! - name: String! - email: String! - } - `), - resolvers: { - Person: { - __resolveReference: (ref) => ({ - id: ref.id, - name: 'John Doe', - email: 'john@doe.com', - }), - }, + const data = { + accounts: [ + { + id: 'a1', + name: 'John Doe', + email: 'john@doe.com', }, - }); + ], + }; + + const accounts = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + people: [Person!]! + } + type Person @key(fields: "id") { + id: ID! + name: String! + email: String! + } + `), + resolvers: { + Person: { + __resolveReference: (ref) => data.accounts.find((a) => a.id === ref.id), + }, + }, + }); + it('should generate stitched schema with node interface', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { diff --git a/yarn.lock b/yarn.lock index e6ca113fa..49897f373 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5090,7 +5090,6 @@ __metadata: "@graphql-tools/wrap": "workspace:^" "@graphql-yoga/typed-event-target": "npm:^3.0.1" "@types/lodash": "npm:4.17.18" - "@wayfair/node-froid": "npm:^3.2.2" "@whatwg-node/disposablestack": "npm:^0.0.6" "@whatwg-node/events": "npm:^0.1.2" "@whatwg-node/fetch": "npm:^0.10.8" @@ -9266,16 +9265,6 @@ __metadata: languageName: node linkType: hard -"@wayfair/node-froid@npm:^3.2.2": - version: 3.2.2 - resolution: "@wayfair/node-froid@npm:3.2.2" - peerDependencies: - graphql: ^16 - graphql-relay: ^0.10 - checksum: 10c0/e3cf2762757308896e2a0965b466905eacf5215c0c257df3695a815279c9186dfce56882ca95363d315a552711a426c6b35c6564be0c60e3f2eb3fe12d929543 - languageName: node - linkType: hard - "@whatwg-node/cookie-store@npm:^0.2.0, @whatwg-node/cookie-store@npm:^0.2.2": version: 0.2.3 resolution: "@whatwg-node/cookie-store@npm:0.2.3" From 23f0c362090a0209e0cf14f4433a30ab29a9cd58 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 10 Jun 2025 00:13:02 +0200 Subject: [PATCH 09/44] point to graphql org --- packages/federation/src/supergraph.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 9ba2ccac5..1736a9034 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -161,7 +161,7 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { * } * ``` * - * @see https://relay.dev/graphql/objectidentification.htm + * @see https://graphql.org/learn/global-object-identification/ */ globalObjectIdentification?: boolean; } From 80dede930de9292649e333cb97b1cb3ee8f6ef64 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 10 Jun 2025 00:26:16 +0200 Subject: [PATCH 10/44] unused --- packages/federation/tests/global-object-identification.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index ada9288ad..e16418084 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -1,8 +1,6 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; -import { normalizedExecutor } from '@graphql-tools/executor'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { parse } from 'graphql'; -import { toGlobalId } from 'graphql-relay'; import { describe, expect, it } from 'vitest'; import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; From 2e5e6635caececc67effc283808f1b4cd06314ad Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 10 Jun 2025 01:16:49 +0200 Subject: [PATCH 11/44] resolve basic person hardcoded --- packages/federation/src/supergraph.ts | 135 +++++++++++------- .../global-object-identification.test.ts | 42 ++++++ 2 files changed, 127 insertions(+), 50 deletions(-) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 1736a9034..c09609a7f 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -43,7 +43,10 @@ import { FieldDefinitionNode, FieldNode, GraphQLFieldResolver, + GraphQLID, GraphQLInterfaceType, + GraphQLNonNull, + GraphQLObjectType, GraphQLOutputType, GraphQLSchema, InputValueDefinitionNode, @@ -68,7 +71,12 @@ import { visit, visitWithTypeInfo, } from 'graphql'; -import { fromGlobalId } from 'graphql-relay'; +import { + fromGlobalId, + globalIdField, + nodeDefinitions, + toGlobalId, +} from 'graphql-relay'; import { filterInternalFieldsAndTypes, getArgsFromKeysForFederation, @@ -1256,60 +1264,87 @@ export function getStitchingOptionsFromSupergraphSdl( if (opts.globalObjectIdentification && typeNameKeysBySubgraphMap.size) { const nodeIdField = 'nodeId'; - const typeDefs = ` - type Query { - """Fetches an object given its globally unique \`ID\`.""" - node( - """The globally unique \`ID\`.""" - ${nodeIdField}: ID! - ): Node - } - interface Node { - """ - A globally unique identifier. Can be used in various places throughout the system to identify this single value. - """ - ${nodeIdField}: ID! - } - ${typeNameKeysBySubgraphMap - .values() - .flatMap((type) => - type.keys().map( - (typeName) => ` - type ${typeName} implements Node { - """ - A globally unique identifier. Can be used in various places throughout the system to identify this single value. - """ - ${nodeIdField}: ID! - }`, - ), - ) - .toArray() - .join('\n')} - `; + + const nodeInterface = new GraphQLInterfaceType({ + name: 'Node', + fields: () => ({ + [nodeIdField]: { + type: new GraphQLNonNull(GraphQLID), + description: + 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', + }, + }), + resolveType: (source: { [nodeIdField]: string }) => { + // TODO: check when runs and with what source, do some logging + return fromGlobalId(source[nodeIdField]).type; + }, + }); + + const types: GraphQLObjectType[] = []; + for (const [typeName, keys] of typeNameKeysBySubgraphMap + .values() + .flatMap((m) => m)) { + // TODO: distinct types with least keys (or respect canonical) + const type = new GraphQLObjectType({ + name: typeName, + interfaces: [nodeInterface], + fields: () => ({ + [nodeIdField]: { + description: + 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', + type: new GraphQLNonNull(GraphQLID), + resolve: (source) => + toGlobalId( + typeName, + // TODO: use keys + source.id, + ), + }, + // TODO: use keys + id: { + type: new GraphQLNonNull(GraphQLID), + }, + }), + }); + types.push(type); + } + const globalObjectIdentSubschema: SubschemaConfig = { name: 'global-object-identification', - schema: makeExecutableSchema({ - typeDefs, - resolvers: { - Query: { - node: (_source, args: { [nodeIdField]: string }) => { - const id = args[nodeIdField]; - if (!fromGlobalId(args[nodeIdField]).type) { - return null; - } - return { - [nodeIdField]: id, - }; - }, - }, - Node: { - __resolveType: (source: { [nodeIdField]: string }) => { - return fromGlobalId(source[nodeIdField]).type; + schema: new GraphQLSchema({ + types, + query: new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + node: { + type: nodeInterface, + description: 'Fetches an object given its globally unique `ID`.', + args: { + [nodeIdField]: { + type: new GraphQLNonNull(GraphQLID), + description: 'The globally unique `ID`.', + }, + }, + resolve: (_source, args: { [nodeIdField]: string }) => { + const nodeId = args[nodeIdField]; + const { id, type } = fromGlobalId(args[nodeIdField]); + if (!type) { + return null; + } + return { + __typename: type, + [nodeIdField]: nodeId, + // TODO: as keys + id, + }; + }, }, - }, - }, + // TODO: nodes + }), + }), }), }; + subschemas.push(globalObjectIdentSubschema); } diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index e16418084..6a3909a7e 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -1,6 +1,8 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; +import { normalizedExecutor } from '@graphql-tools/executor'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { parse } from 'graphql'; +import { toGlobalId } from 'graphql-relay'; import { describe, expect, it } from 'vitest'; import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; @@ -73,4 +75,44 @@ describe('Global Object Identification', () => { }" `); }); + + it('should resolve object from globally unique node', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + accounts, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Person', 'a1')}") { + nodeId + ... on Person { + id + name + email + } + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "email": "john@doe.com", + "id": "a1", + "name": "John Doe", + "nodeId": "UGVyc29uOmEx", + }, + }, + } + `); + }); }); From 1a0a0d1f78e42770baa3f3ab55866b8e9992771f Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 10 Jun 2025 02:07:01 +0200 Subject: [PATCH 12/44] progress progress --- packages/federation/src/supergraph.ts | 121 ++++++++++++++++++++------ 1 file changed, 94 insertions(+), 27 deletions(-) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index c09609a7f..b13d26a43 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -42,6 +42,7 @@ import { EnumValueDefinitionNode, FieldDefinitionNode, FieldNode, + GraphQLFieldConfig, GraphQLFieldResolver, GraphQLID, GraphQLInterfaceType, @@ -1280,39 +1281,105 @@ export function getStitchingOptionsFromSupergraphSdl( }, }); - const types: GraphQLObjectType[] = []; - for (const [typeName, keys] of typeNameKeysBySubgraphMap - .values() - .flatMap((m) => m)) { - // TODO: distinct types with least keys (or respect canonical) - const type = new GraphQLObjectType({ - name: typeName, - interfaces: [nodeInterface], - fields: () => ({ - [nodeIdField]: { - description: - 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', - type: new GraphQLNonNull(GraphQLID), - resolve: (source) => - toGlobalId( - typeName, - // TODO: use keys - source.id, + const types = new Map(); + for (const [subgraphName, typeNameKeys] of typeNameKeysBySubgraphMap) { + typeNames: for (const [typeName, keys] of typeNameKeys) { + for (const key of keys.sort( + (a, b) => + // sort by shorter keys first + // TODO: respect canonical keys + a.length - b.length, + )) { + if (types.has(typeName)) { + // type already constructed to be resolved by global object identification + // with the best key, skip it in all other occurrences + continue typeNames; + } + + if ( + // the key for fetching this object contains other objects, + key.includes('{') || + // the key for fetching this object contains arguments, + key.includes('(') + ) { + // it's too complex to use global object identification + // TODO: do it anyways when need arises + continue; + } + if (key.includes(':')) { + throw new Error( + 'Aliases in @key fields are not supported by Global Object Identification yet.', + ); + } + + // what we're left in the "key" are simple field(s) like "id" or "email" + const fieldNames = key.trim().split(/\s+/); + if (fieldNames.includes(nodeIdField)) { + throw new Error( + `The field "${nodeIdField}" is reserved for Global Object Identification and cannot be used in @key fields for type "${typeName}".`, + ); + } + + const schema = subschemas.find( + (s) => s.name === subgraphName, + )?.schema; + if (!schema) { + throw new Error( + `Subgraph "${subgraphName}" not found for Global Object Identification.`, + ); + } + + const objectInSubgraph = schema.getType( + typeName, + ) as GraphQLObjectType; + const fieldsOfObjectInSubgraph = objectInSubgraph.getFields(); + + // TODO: distinct types with least keys (or respect canonical) + const object = new GraphQLObjectType({ + name: typeName, + interfaces: [nodeInterface], + fields: () => ({ + [nodeIdField]: { + description: + 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', + type: new GraphQLNonNull(GraphQLID), + resolve: (source) => + toGlobalId( + typeName, + // TODO: use keys + source.id, + ), + }, + ...fieldNames.reduce( + (acc, fieldName) => { + const fieldInSubgraph = fieldsOfObjectInSubgraph[fieldName]; + if (!fieldInSubgraph) { + throw new Error( + `Field "${fieldName}" not found in type "${typeName}" in subgraph "${subgraphName}".`, + ); + } + return { + ...acc, + [fieldName]: { + // TODO: other config? necessary? + type: fieldInSubgraph.type, + }, + }; + }, + {} as Record>, ), - }, - // TODO: use keys - id: { - type: new GraphQLNonNull(GraphQLID), - }, - }), - }); - types.push(type); + }), + }); + + types.set(typeName, object); + } + } } const globalObjectIdentSubschema: SubschemaConfig = { name: 'global-object-identification', schema: new GraphQLSchema({ - types, + types: types.values().toArray(), query: new GraphQLObjectType({ name: 'Query', fields: () => ({ From f8c54002c61c9d4a077ac99786c1a74e357bc8cd Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 11:22:29 +0200 Subject: [PATCH 13/44] handle multiple fields keys --- packages/federation/src/supergraph.ts | 94 ++++++++++++++++++--------- 1 file changed, 62 insertions(+), 32 deletions(-) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index b13d26a43..52327a20c 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -16,7 +16,6 @@ import { buildHTTPExecutor, HTTPExecutorOptions, } from '@graphql-tools/executor-http'; -import { makeExecutableSchema } from '@graphql-tools/schema'; import { calculateSelectionScore, getDefaultFieldConfigMerger, @@ -72,12 +71,7 @@ import { visit, visitWithTypeInfo, } from 'graphql'; -import { - fromGlobalId, - globalIdField, - nodeDefinitions, - toGlobalId, -} from 'graphql-relay'; +import { fromGlobalId, toGlobalId } from 'graphql-relay'; import { filterInternalFieldsAndTypes, getArgsFromKeysForFederation, @@ -1281,7 +1275,10 @@ export function getStitchingOptionsFromSupergraphSdl( }, }); - const types = new Map(); + const types = new Map< + string, + { object: GraphQLObjectType; keyFieldNames: string[] } + >(); for (const [subgraphName, typeNameKeys] of typeNameKeysBySubgraphMap) { typeNames: for (const [typeName, keys] of typeNameKeys) { for (const key of keys.sort( @@ -1297,9 +1294,9 @@ export function getStitchingOptionsFromSupergraphSdl( } if ( - // the key for fetching this object contains other objects, + // the key for fetching this object contains other objects key.includes('{') || - // the key for fetching this object contains arguments, + // the key for fetching this object contains arguments key.includes('(') ) { // it's too complex to use global object identification @@ -1313,8 +1310,8 @@ export function getStitchingOptionsFromSupergraphSdl( } // what we're left in the "key" are simple field(s) like "id" or "email" - const fieldNames = key.trim().split(/\s+/); - if (fieldNames.includes(nodeIdField)) { + const keyFieldNames = key.trim().split(/\s+/); + if (keyFieldNames.includes(nodeIdField)) { throw new Error( `The field "${nodeIdField}" is reserved for Global Object Identification and cannot be used in @key fields for type "${typeName}".`, ); @@ -1334,7 +1331,6 @@ export function getStitchingOptionsFromSupergraphSdl( ) as GraphQLObjectType; const fieldsOfObjectInSubgraph = objectInSubgraph.getFields(); - // TODO: distinct types with least keys (or respect canonical) const object = new GraphQLObjectType({ name: typeName, interfaces: [nodeInterface], @@ -1343,14 +1339,21 @@ export function getStitchingOptionsFromSupergraphSdl( description: 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', type: new GraphQLNonNull(GraphQLID), - resolve: (source) => - toGlobalId( - typeName, - // TODO: use keys - source.id, - ), + resolve: (source) => { + if (keyFieldNames.length === 1) { + // single field key + return toGlobalId(typeName, source[keyFieldNames[0]!]); + } + // multiple fields key + const fields: Record = {}; + for (const fieldName of keyFieldNames) { + // loop is faster than reduce + fields[fieldName] = source[fieldName]; + } + return toGlobalId(typeName, JSON.stringify(fields)); + }, }, - ...fieldNames.reduce( + ...keyFieldNames.reduce( (acc, fieldName) => { const fieldInSubgraph = fieldsOfObjectInSubgraph[fieldName]; if (!fieldInSubgraph) { @@ -1361,7 +1364,7 @@ export function getStitchingOptionsFromSupergraphSdl( return { ...acc, [fieldName]: { - // TODO: other config? necessary? + // TODO: copy other field config from subgraph type? necessary? type: fieldInSubgraph.type, }, }; @@ -1371,7 +1374,7 @@ export function getStitchingOptionsFromSupergraphSdl( }), }); - types.set(typeName, object); + types.set(typeName, { object, keyFieldNames }); } } } @@ -1379,10 +1382,14 @@ export function getStitchingOptionsFromSupergraphSdl( const globalObjectIdentSubschema: SubschemaConfig = { name: 'global-object-identification', schema: new GraphQLSchema({ - types: types.values().toArray(), + types: types + .values() + .map(({ object }) => object) + .toArray(), query: new GraphQLObjectType({ name: 'Query', fields: () => ({ + // TODO: nodes(ids: [ID!]!): [Node!]! node: { type: nodeInterface, description: 'Fetches an object given its globally unique `ID`.', @@ -1394,19 +1401,42 @@ export function getStitchingOptionsFromSupergraphSdl( }, resolve: (_source, args: { [nodeIdField]: string }) => { const nodeId = args[nodeIdField]; - const { id, type } = fromGlobalId(args[nodeIdField]); - if (!type) { + const { id: idOrFields, type } = fromGlobalId( + args[nodeIdField], + ); + if (!idOrFields || !type) { + return null; + } + const { keyFieldNames } = types.get(type) || {}; + if (!keyFieldNames) { + return null; + } + if (keyFieldNames.length === 1) { + // single field key + return { + __typename: type, + [nodeIdField]: nodeId, + [keyFieldNames[0]!]: idOrFields, + }; + } + // multiple fields key + try { + const fields: Record = {}; + const idFields = JSON.parse(idOrFields); + for (const fieldName of keyFieldNames) { + // loop is faster than reduce + fields[fieldName] = idFields[fieldName]; + } + return { + __typename: type, + [nodeIdField]: nodeId, + ...fields, + }; + } catch { return null; } - return { - __typename: type, - [nodeIdField]: nodeId, - // TODO: as keys - id, - }; }, }, - // TODO: nodes }), }), }), From 9904c238cf6e34a5daf57f2b84c3c84861b99766 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 11:25:58 +0200 Subject: [PATCH 14/44] people --- .../tests/global-object-identification.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index 6a3909a7e..a3c615a59 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -17,7 +17,7 @@ describe('Global Object Identification', () => { ], }; - const accounts = buildSubgraphSchema({ + const people = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` type Query { people: [Person!]! @@ -29,6 +29,9 @@ describe('Global Object Identification', () => { } `), resolvers: { + Query: { + people: () => data.accounts, + }, Person: { __resolveReference: (ref) => data.accounts.find((a) => a.id === ref.id), }, @@ -39,7 +42,7 @@ describe('Global Object Identification', () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { - accounts, + people, }, }); @@ -80,7 +83,7 @@ describe('Global Object Identification', () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { - accounts, + people, }, }); From a092309ba0370a4488a7bb44c7ba923d8222cae7 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 11:32:43 +0200 Subject: [PATCH 15/44] test for nodeid --- .../global-object-identification.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index a3c615a59..22daee834 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -118,4 +118,44 @@ describe('Global Object Identification', () => { } `); }); + + it('should resolve node id from object', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + people, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + people { + nodeId + id + name + email + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "people": [ + { + "email": "john@doe.com", + "id": "a1", + "name": "John Doe", + "nodeId": "UGVyc29uOmEx", + }, + ], + }, + } + `); + }); }); From a3e6c2345d287ee378c9e6906cbb813b529f0291 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 12:26:39 +0200 Subject: [PATCH 16/44] resolve nodeId --- packages/federation/src/supergraph.ts | 67 +++++++++++++++++++-------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 52327a20c..ae41bc884 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -1269,15 +1269,17 @@ export function getStitchingOptionsFromSupergraphSdl( 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', }, }), - resolveType: (source: { [nodeIdField]: string }) => { - // TODO: check when runs and with what source, do some logging - return fromGlobalId(source[nodeIdField]).type; - }, + // NOTE: resolve type from the nodeId is not necessary because Query.node will do that + resolveType: ({ __typename }: { __typename: string }) => __typename, }); const types = new Map< string, - { object: GraphQLObjectType; keyFieldNames: string[] } + { + object: GraphQLObjectType; + keySelectionSet: string; + keyFieldNames: string[]; + } >(); for (const [subgraphName, typeNameKeys] of typeNameKeysBySubgraphMap) { typeNames: for (const [typeName, keys] of typeNameKeys) { @@ -1339,19 +1341,7 @@ export function getStitchingOptionsFromSupergraphSdl( description: 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', type: new GraphQLNonNull(GraphQLID), - resolve: (source) => { - if (keyFieldNames.length === 1) { - // single field key - return toGlobalId(typeName, source[keyFieldNames[0]!]); - } - // multiple fields key - const fields: Record = {}; - for (const fieldName of keyFieldNames) { - // loop is faster than reduce - fields[fieldName] = source[fieldName]; - } - return toGlobalId(typeName, JSON.stringify(fields)); - }, + // NOTE: resolve function is not necessary here because Query.node will provide all fields }, ...keyFieldNames.reduce( (acc, fieldName) => { @@ -1364,8 +1354,8 @@ export function getStitchingOptionsFromSupergraphSdl( return { ...acc, [fieldName]: { - // TODO: copy other field config from subgraph type? necessary? type: fieldInSubgraph.type, + // NOTE: resolve function is not necessary for the rest of fields because this subgraph will never resolve them }, }; }, @@ -1374,13 +1364,50 @@ export function getStitchingOptionsFromSupergraphSdl( }), }); - types.set(typeName, { object, keyFieldNames }); + types.set(typeName, { + object, + keySelectionSet: `{ ${key} }`, + keyFieldNames, + }); } } } const globalObjectIdentSubschema: SubschemaConfig = { + // TODO: make sure there is no conflict with other subschemas name: 'global-object-identification', + merge: { + ...types.entries().reduce( + (acc, [typeName, { keySelectionSet, keyFieldNames }]) => ({ + ...acc, + [typeName]: { + fieldName: 'node', + selectionSet: keySelectionSet, + args: (source) => { + if (keyFieldNames.length === 1) { + // single field key + return { + [nodeIdField]: toGlobalId( + typeName, + source[keyFieldNames[0]!], + ), + }; + } + // multiple fields key + const fields: Record = {}; + for (const fieldName of keyFieldNames) { + // loop is faster than reduce + fields[fieldName] = source[fieldName]; + } + return { + [nodeIdField]: toGlobalId(typeName, JSON.stringify(fields)), + }; + }, + }, + }), + {} as Record, + ), + }, schema: new GraphQLSchema({ types: types .values() From c52d431aa3727d0d038cd86c258ef4d05bf4b77c Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 12:27:52 +0200 Subject: [PATCH 17/44] accounts --- .../global-object-identification.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index 22daee834..b8fcd90c5 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -17,12 +17,12 @@ describe('Global Object Identification', () => { ], }; - const people = buildSubgraphSchema({ + const accounts = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` type Query { - people: [Person!]! + accounts: [Account!]! } - type Person @key(fields: "id") { + type Account @key(fields: "id") { id: ID! name: String! email: String! @@ -30,9 +30,9 @@ describe('Global Object Identification', () => { `), resolvers: { Query: { - people: () => data.accounts, + accounts: () => data.accounts, }, - Person: { + Account: { __resolveReference: (ref) => data.accounts.find((a) => a.id === ref.id), }, }, @@ -42,7 +42,7 @@ describe('Global Object Identification', () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { - people, + accounts, }, }); @@ -52,7 +52,7 @@ describe('Global Object Identification', () => { } type Query { - people: [Person!]! + accounts: [Account!]! """Fetches an object given its globally unique \`ID\`.""" node( """The globally unique \`ID\`.""" @@ -60,7 +60,7 @@ describe('Global Object Identification', () => { ): Node } - type Person implements Node { + type Account implements Node { id: ID! name: String! email: String! @@ -83,7 +83,7 @@ describe('Global Object Identification', () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { - people, + accounts, }, }); @@ -93,9 +93,9 @@ describe('Global Object Identification', () => { schema, document: parse(/* GraphQL */ ` { - node(nodeId: "${toGlobalId('Person', 'a1')}") { + node(nodeId: "${toGlobalId('Account', 'a1')}") { nodeId - ... on Person { + ... on Account { id name email @@ -112,7 +112,7 @@ describe('Global Object Identification', () => { "email": "john@doe.com", "id": "a1", "name": "John Doe", - "nodeId": "UGVyc29uOmEx", + "nodeId": "QWNjb3VudDphMQ==", }, }, } @@ -123,7 +123,7 @@ describe('Global Object Identification', () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { - people, + accounts, }, }); @@ -133,7 +133,7 @@ describe('Global Object Identification', () => { schema, document: parse(/* GraphQL */ ` { - people { + accounts { nodeId id name @@ -146,12 +146,12 @@ describe('Global Object Identification', () => { ).resolves.toMatchInlineSnapshot(` { "data": { - "people": [ + "accounts": [ { "email": "john@doe.com", "id": "a1", "name": "John Doe", - "nodeId": "UGVyc29uOmEx", + "nodeId": "QWNjb3VudDphMQ==", }, ], }, From a0745305d68e33e919e1b29ec8b5fd611efa015c Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 12:32:44 +0200 Subject: [PATCH 18/44] multiple fields key --- .../global-object-identification.test.ts | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/global-object-identification.test.ts index b8fcd90c5..5d8240e31 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/global-object-identification.test.ts @@ -15,6 +15,18 @@ describe('Global Object Identification', () => { email: 'john@doe.com', }, ], + people: [ + { + firstName: 'John', + lastName: 'Doe', + email: 'john@doe.com', + }, + { + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@doe.com', + }, + ], }; const accounts = buildSubgraphSchema({ @@ -38,6 +50,30 @@ describe('Global Object Identification', () => { }, }); + const people = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + people: [Person!]! + } + type Person @key(fields: "firstName lastName") { + firstName: String! + lastName: String! + email: String! + } + `), + resolvers: { + Query: { + people: () => data.people, + }, + Person: { + __resolveReference: (ref) => + data.people.find( + (a) => a.firstName === ref.firstName && a.lastName === ref.lastName, + ), + }, + }, + }); + it('should generate stitched schema with node interface', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, @@ -79,7 +115,7 @@ describe('Global Object Identification', () => { `); }); - it('should resolve object from globally unique node', async () => { + it('should resolve single field key object from globally unique node', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { @@ -119,6 +155,44 @@ describe('Global Object Identification', () => { `); }); + it('should resolve multiple fields key object from globally unique node', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + people, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Person', JSON.stringify({ firstName: 'John', lastName: 'Doe' }))}") { + nodeId + ... on Person { + firstName + lastName + } + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "firstName": "John", + "lastName": "Doe", + "nodeId": "UGVyc29uOnsiZmlyc3ROYW1lIjoiSm9obiIsImxhc3ROYW1lIjoiRG9lIn0=", + }, + }, + } + `); + }); + it('should resolve node id from object', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, From 07fcd8a94c2e1f550ac7f92fcf633a29c318e5f4 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 10 Jun 2025 21:35:19 +0200 Subject: [PATCH 19/44] use additional resolvers, better --- .../src/globalObjectIdentification.ts | 213 ++++++++++++++ packages/federation/src/supergraph.ts | 260 +++--------------- ....ts => globalObjectIdentification.test.ts} | 35 +++ 3 files changed, 282 insertions(+), 226 deletions(-) create mode 100644 packages/federation/src/globalObjectIdentification.ts rename packages/federation/tests/{global-object-identification.test.ts => globalObjectIdentification.test.ts} (87%) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts new file mode 100644 index 000000000..870bc2032 --- /dev/null +++ b/packages/federation/src/globalObjectIdentification.ts @@ -0,0 +1,213 @@ +import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; +import { SubschemaConfig } from '@graphql-tools/delegate'; +import { IResolvers } from '@graphql-tools/utils'; +import { + DefinitionNode, + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + Kind, + ObjectTypeExtensionNode, +} from 'graphql'; +import { fromGlobalId, toGlobalId } from 'graphql-relay'; +import { MergedTypeConfigFromEntities } from './supergraph'; + +export interface GlobalObjectIdentificationOptions { + nodeIdField: string; + subschemas: SubschemaConfig[]; +} + +export function createNodeDefinitions({ + nodeIdField, + subschemas, +}: GlobalObjectIdentificationOptions) { + const defs: DefinitionNode[] = []; + + // nodeId: ID + + const nodeIdFieldDef: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: { + kind: Kind.NAME, + value: nodeIdField, + }, + description: { + kind: Kind.STRING, + value: + 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', + }, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'ID', + }, + }, + }, + }; + + // interface Node + + const nodeInterfaceDef: InterfaceTypeDefinitionNode = { + kind: Kind.INTERFACE_TYPE_DEFINITION, + name: { + kind: Kind.NAME, + value: 'Node', + }, + fields: [nodeIdFieldDef], + }; + + defs.push(nodeInterfaceDef); + + // extend type X implements Node + + for (const { typeName } of getResolveableTypes(subschemas)) { + const typeExtensionDef: ObjectTypeExtensionNode = { + kind: Kind.OBJECT_TYPE_EXTENSION, + name: { + kind: Kind.NAME, + value: typeName, + }, + interfaces: [ + { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Node', + }, + }, + ], + fields: [nodeIdFieldDef], + }; + defs.push(typeExtensionDef); + } + + // extend type Query { nodeId: ID! } + + const queryExtensionDef: ObjectTypeExtensionNode = { + kind: Kind.OBJECT_TYPE_EXTENSION, + name: { + kind: Kind.NAME, + value: 'Query', + }, + fields: [ + { + kind: Kind.FIELD_DEFINITION, + name: { + kind: Kind.NAME, + value: 'node', + }, + description: { + kind: Kind.STRING, + value: 'Fetches an object given its globally unique `ID`.', + }, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Node', + }, + }, + arguments: [ + { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { + kind: Kind.NAME, + value: nodeIdField, + }, + description: { + kind: Kind.STRING, + value: 'The globally unique `ID`.', + }, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'ID', + }, + }, + }, + }, + ], + }, + ], + }; + + defs.push(queryExtensionDef); + + return defs; +} + +export function createResolvers({ + nodeIdField, + subschemas, +}: GlobalObjectIdentificationOptions): IResolvers { + const types = getResolveableTypesMap(subschemas); + return { + Query: { + node(_source, { nodeId }, context, info) { + const { id, type: typeName } = fromGlobalId(nodeId); + const { subschema, merge } = types[typeName] || {}; + if (!subschema || !merge) { + return null; + } + const { key, selectionSet, ...batchOpts } = merge; + return batchDelegateToSchema({ + ...batchOpts, + schema: subschema, + key: { __typename: typeName, id }, // TODO: use keys + info, + context, + valuesFromResults: (results) => + // add the nodeId field to the results + results.map((r: any) => + !r ? null : { ...r, [nodeIdField]: nodeId }, + ), + }); + }, + }, + Account: { + [nodeIdField](source) { + return toGlobalId('Account', source.id); // TODO: use keys + }, + }, + }; +} + +function* getResolveableTypes(subschemas: Iterable) { + for (const subschema of subschemas) { + for (const [typeName, merge] of Object.entries(subschema.merge || {})) { + if ( + !merge.selectionSet || + !merge.argsFromKeys || + !merge.key || + !merge.fieldName || + !merge.dataLoaderOptions + ) { + continue; + } + // TODO: provide the best and shortest path type + yield { + typeName, + subschema, + merge: merge as MergedTypeConfigFromEntities, + }; + } + } +} + +function getResolveableTypesMap(subschemas: Iterable) { + const types: Record< + string, + { subschema: SubschemaConfig; merge: MergedTypeConfigFromEntities } + > = {}; + for (const { typeName, merge, subschema } of getResolveableTypes( + subschemas, + )) { + types[typeName] = { subschema, merge }; + } + return types; +} diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index ae41bc884..faa16cd97 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -41,12 +41,8 @@ import { EnumValueDefinitionNode, FieldDefinitionNode, FieldNode, - GraphQLFieldConfig, GraphQLFieldResolver, - GraphQLID, GraphQLInterfaceType, - GraphQLNonNull, - GraphQLObjectType, GraphQLOutputType, GraphQLSchema, InputValueDefinitionNode, @@ -71,7 +67,10 @@ import { visit, visitWithTypeInfo, } from 'graphql'; -import { fromGlobalId, toGlobalId } from 'graphql-relay'; +import { + createNodeDefinitions, + createResolvers, +} from './globalObjectIdentification.js'; import { filterInternalFieldsAndTypes, getArgsFromKeysForFederation, @@ -872,7 +871,9 @@ export function getStitchingOptionsFromSupergraphSdl( mergedTypeConfig.canonical = true; } - function getMergedTypeConfigFromKey(key: string) { + function getMergedTypeConfigFromKey( + key: string, + ): MergedTypeConfigFromEntities { return { selectionSet: `{ ${key} }`, argsFromKeys: getArgsFromKeysForFederation, @@ -1257,221 +1258,6 @@ export function getStitchingOptionsFromSupergraphSdl( subschemas.push(subschemaConfig); } - if (opts.globalObjectIdentification && typeNameKeysBySubgraphMap.size) { - const nodeIdField = 'nodeId'; - - const nodeInterface = new GraphQLInterfaceType({ - name: 'Node', - fields: () => ({ - [nodeIdField]: { - type: new GraphQLNonNull(GraphQLID), - description: - 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', - }, - }), - // NOTE: resolve type from the nodeId is not necessary because Query.node will do that - resolveType: ({ __typename }: { __typename: string }) => __typename, - }); - - const types = new Map< - string, - { - object: GraphQLObjectType; - keySelectionSet: string; - keyFieldNames: string[]; - } - >(); - for (const [subgraphName, typeNameKeys] of typeNameKeysBySubgraphMap) { - typeNames: for (const [typeName, keys] of typeNameKeys) { - for (const key of keys.sort( - (a, b) => - // sort by shorter keys first - // TODO: respect canonical keys - a.length - b.length, - )) { - if (types.has(typeName)) { - // type already constructed to be resolved by global object identification - // with the best key, skip it in all other occurrences - continue typeNames; - } - - if ( - // the key for fetching this object contains other objects - key.includes('{') || - // the key for fetching this object contains arguments - key.includes('(') - ) { - // it's too complex to use global object identification - // TODO: do it anyways when need arises - continue; - } - if (key.includes(':')) { - throw new Error( - 'Aliases in @key fields are not supported by Global Object Identification yet.', - ); - } - - // what we're left in the "key" are simple field(s) like "id" or "email" - const keyFieldNames = key.trim().split(/\s+/); - if (keyFieldNames.includes(nodeIdField)) { - throw new Error( - `The field "${nodeIdField}" is reserved for Global Object Identification and cannot be used in @key fields for type "${typeName}".`, - ); - } - - const schema = subschemas.find( - (s) => s.name === subgraphName, - )?.schema; - if (!schema) { - throw new Error( - `Subgraph "${subgraphName}" not found for Global Object Identification.`, - ); - } - - const objectInSubgraph = schema.getType( - typeName, - ) as GraphQLObjectType; - const fieldsOfObjectInSubgraph = objectInSubgraph.getFields(); - - const object = new GraphQLObjectType({ - name: typeName, - interfaces: [nodeInterface], - fields: () => ({ - [nodeIdField]: { - description: - 'A globally unique identifier. Can be used in various places throughout the system to identify this single value.', - type: new GraphQLNonNull(GraphQLID), - // NOTE: resolve function is not necessary here because Query.node will provide all fields - }, - ...keyFieldNames.reduce( - (acc, fieldName) => { - const fieldInSubgraph = fieldsOfObjectInSubgraph[fieldName]; - if (!fieldInSubgraph) { - throw new Error( - `Field "${fieldName}" not found in type "${typeName}" in subgraph "${subgraphName}".`, - ); - } - return { - ...acc, - [fieldName]: { - type: fieldInSubgraph.type, - // NOTE: resolve function is not necessary for the rest of fields because this subgraph will never resolve them - }, - }; - }, - {} as Record>, - ), - }), - }); - - types.set(typeName, { - object, - keySelectionSet: `{ ${key} }`, - keyFieldNames, - }); - } - } - } - - const globalObjectIdentSubschema: SubschemaConfig = { - // TODO: make sure there is no conflict with other subschemas - name: 'global-object-identification', - merge: { - ...types.entries().reduce( - (acc, [typeName, { keySelectionSet, keyFieldNames }]) => ({ - ...acc, - [typeName]: { - fieldName: 'node', - selectionSet: keySelectionSet, - args: (source) => { - if (keyFieldNames.length === 1) { - // single field key - return { - [nodeIdField]: toGlobalId( - typeName, - source[keyFieldNames[0]!], - ), - }; - } - // multiple fields key - const fields: Record = {}; - for (const fieldName of keyFieldNames) { - // loop is faster than reduce - fields[fieldName] = source[fieldName]; - } - return { - [nodeIdField]: toGlobalId(typeName, JSON.stringify(fields)), - }; - }, - }, - }), - {} as Record, - ), - }, - schema: new GraphQLSchema({ - types: types - .values() - .map(({ object }) => object) - .toArray(), - query: new GraphQLObjectType({ - name: 'Query', - fields: () => ({ - // TODO: nodes(ids: [ID!]!): [Node!]! - node: { - type: nodeInterface, - description: 'Fetches an object given its globally unique `ID`.', - args: { - [nodeIdField]: { - type: new GraphQLNonNull(GraphQLID), - description: 'The globally unique `ID`.', - }, - }, - resolve: (_source, args: { [nodeIdField]: string }) => { - const nodeId = args[nodeIdField]; - const { id: idOrFields, type } = fromGlobalId( - args[nodeIdField], - ); - if (!idOrFields || !type) { - return null; - } - const { keyFieldNames } = types.get(type) || {}; - if (!keyFieldNames) { - return null; - } - if (keyFieldNames.length === 1) { - // single field key - return { - __typename: type, - [nodeIdField]: nodeId, - [keyFieldNames[0]!]: idOrFields, - }; - } - // multiple fields key - try { - const fields: Record = {}; - const idFields = JSON.parse(idOrFields); - for (const fieldName of keyFieldNames) { - // loop is faster than reduce - fields[fieldName] = idFields[fieldName]; - } - return { - __typename: type, - [nodeIdField]: nodeId, - ...fields, - }; - } catch { - return null; - } - }, - }, - }), - }), - }), - }; - - subschemas.push(globalObjectIdentSubschema); - } - const defaultMerger = getDefaultFieldConfigMerger(true); const fieldConfigMerger: TypeMergingOptions['fieldConfigMerger'] = function ( candidates: MergeFieldConfigCandidate[], @@ -1759,20 +1545,35 @@ export function getStitchingOptionsFromSupergraphSdl( extraDefinitions.push(definition); } } - const additionalTypeDefs: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: extraDefinitions, - }; if (opts.onSubschemaConfig) { for (const subschema of subschemas) { opts.onSubschemaConfig(subschema as FederationSubschemaConfig); } } + const shouldGlobalObjectIdent = + opts.globalObjectIdentification && typeNameKeysBySubgraphMap.size; return { subschemas, - typeDefs: additionalTypeDefs, + typeDefs: { + kind: Kind.DOCUMENT, + definitions: !shouldGlobalObjectIdent + ? extraDefinitions + : [ + ...extraDefinitions, + ...createNodeDefinitions({ + nodeIdField: 'nodeId', + subschemas, + }), + ], + } as DocumentNode, assumeValid: true, assumeValidSDL: true, + resolvers: !shouldGlobalObjectIdent + ? undefined + : createResolvers({ + nodeIdField: 'nodeId', + subschemas, + }), typeMergingOptions: { useNonNullableFieldOnConflict: true, validationSettings: { @@ -1946,3 +1747,10 @@ function mergeResults(results: unknown[], getFieldNames: () => Set) { } return null; } + +export type MergedTypeConfigFromEntities = Required< + Pick< + MergedTypeConfig, + 'selectionSet' | 'argsFromKeys' | 'key' | 'fieldName' | 'dataLoaderOptions' + > +>; diff --git a/packages/federation/tests/global-object-identification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts similarity index 87% rename from packages/federation/tests/global-object-identification.test.ts rename to packages/federation/tests/globalObjectIdentification.test.ts index 5d8240e31..14cc6a7ef 100644 --- a/packages/federation/tests/global-object-identification.test.ts +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -155,6 +155,41 @@ describe('Global Object Identification', () => { `); }); + it('should not resolve single field key object from globally unique node when doesnt exist', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + accounts, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Account', 'dontexist1')}") { + nodeId + ... on Account { + id + name + email + } + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": null, + }, + } + `); + }); + it('should resolve multiple fields key object from globally unique node', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, From b5cdb7cdd08d9108932ea634aed0374306e419a5 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 11 Jun 2025 13:36:07 +0200 Subject: [PATCH 20/44] handle remainders --- .../src/globalObjectIdentification.ts | 117 +++++++++++++----- 1 file changed, 86 insertions(+), 31 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 870bc2032..44749a738 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -62,7 +62,7 @@ export function createNodeDefinitions({ // extend type X implements Node - for (const { typeName } of getResolveableTypes(subschemas)) { + for (const { typeName } of getDistinctResolvableTypes(subschemas)) { const typeExtensionDef: ObjectTypeExtensionNode = { kind: Kind.OBJECT_TYPE_EXTENSION, name: { @@ -145,22 +145,61 @@ export function createResolvers({ nodeIdField, subschemas, }: GlobalObjectIdentificationOptions): IResolvers { - const types = getResolveableTypesMap(subschemas); + const types = getDistinctResolvableTypes(subschemas).toArray(); return { + ...types.reduce( + (resolvers, { typeName, keyFieldNames }) => ({ + ...resolvers, + [typeName]: { + [nodeIdField](source) { + if (keyFieldNames.length === 1) { + // single field key + return toGlobalId(typeName, source[keyFieldNames[0]!]); + } + // multiple fields key + const keyFields: Record = {}; + for (const fieldName of keyFieldNames) { + // loop is faster than reduce + keyFields[fieldName] = source[fieldName]; + } + return toGlobalId(typeName, JSON.stringify(keyFields)); + }, + }, + }), + {} as Record, + ), Query: { node(_source, { nodeId }, context, info) { - const { id, type: typeName } = fromGlobalId(nodeId); - const { subschema, merge } = types[typeName] || {}; - if (!subschema || !merge) { - return null; + const { id: idOrFields, type: typeName } = fromGlobalId(nodeId); + const type = types.find((t) => t.typeName === typeName); + if (!type) { + return null; // unknown type } - const { key, selectionSet, ...batchOpts } = merge; + + const keyFields: Record = {}; + if (type.keyFieldNames.length === 1) { + // single field key + keyFields[type.keyFieldNames[0]!] = idOrFields; + } else { + // multiple fields key + try { + const idFields = JSON.parse(idOrFields); + for (const fieldName of type.keyFieldNames) { + // loop is faster than reduce + keyFields[fieldName] = idFields[fieldName]; + } + } catch { + return null; // invalid JSON i.e. invalid global ID + } + } + return batchDelegateToSchema({ - ...batchOpts, - schema: subschema, - key: { __typename: typeName, id }, // TODO: use keys + ...type.merge, info, context, + schema: type.subschema, + selectionSet: undefined, // selectionSet is not needed here + key: { ...keyFields, __typename: typeName }, // we already have all the necessary keys valuesFromResults: (results) => // add the nodeId field to the results results.map((r: any) => @@ -169,17 +208,27 @@ export function createResolvers({ }); }, }, - Account: { - [nodeIdField](source) { - return toGlobalId('Account', source.id); // TODO: use keys - }, - }, }; } -function* getResolveableTypes(subschemas: Iterable) { +function* getDistinctResolvableTypes(subschemas: Iterable) { + const yieldedTypes = new Set(); for (const subschema of subschemas) { - for (const [typeName, merge] of Object.entries(subschema.merge || {})) { + // TODO: respect canonical types + for (const [typeName, merge] of Object.entries(subschema.merge || {}) + .filter( + // make sure selectionset is defined for the sort to work + ([, merge]) => merge.selectionSet, + ) + .sort( + // sort by shortest keys first + ([, a], [, b]) => a.selectionSet!.length - b.selectionSet!.length, + )) { + if (yieldedTypes.has(typeName)) { + // already yielded this type, all types can only have one resolution + continue; + } + if ( !merge.selectionSet || !merge.argsFromKeys || @@ -187,27 +236,33 @@ function* getResolveableTypes(subschemas: Iterable) { !merge.fieldName || !merge.dataLoaderOptions ) { + // cannot be resolved globally continue; } - // TODO: provide the best and shortest path type + + // remove first and last characters from the selection set making up the key (curly braces, `{ id } -> id`) + const key = merge.selectionSet.trim().slice(1, -1).trim(); + if ( + // the key for fetching this object contains other objects + key.includes('{') || + // the key for fetching this object contains arguments + key.includes('(') || + // the key contains aliases + key.includes(':') + ) { + // it's too complex to use global object identification + // TODO: do it anyways when need arises + continue; + } + // what we're left in the "key" are simple field(s) like "id" or "email" + + yieldedTypes.add(typeName); yield { typeName, subschema, merge: merge as MergedTypeConfigFromEntities, + keyFieldNames: key.trim().split(/\s+/), }; } } } - -function getResolveableTypesMap(subschemas: Iterable) { - const types: Record< - string, - { subschema: SubschemaConfig; merge: MergedTypeConfigFromEntities } - > = {}; - for (const { typeName, merge, subschema } of getResolveableTypes( - subschemas, - )) { - types[typeName] = { subschema, merge }; - } - return types; -} From 5ec5fbf63cc30e8281773af6a840415083323d15 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 11 Jun 2025 13:43:23 +0200 Subject: [PATCH 21/44] batch deleg --- packages/federation/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/federation/package.json b/packages/federation/package.json index 9febfa147..f37963643 100644 --- a/packages/federation/package.json +++ b/packages/federation/package.json @@ -38,6 +38,7 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" }, "dependencies": { + "@graphql-tools/batch-delegate": "workspace:^", "@graphql-tools/delegate": "workspace:^", "@graphql-tools/executor": "^1.4.7", "@graphql-tools/executor-http": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 49897f373..2b86aefea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5080,6 +5080,7 @@ __metadata: "@apollo/server": "npm:^4.12.2" "@apollo/server-gateway-interface": "npm:^1.1.1" "@apollo/subgraph": "npm:^2.11.0" + "@graphql-tools/batch-delegate": "workspace:^" "@graphql-tools/delegate": "workspace:^" "@graphql-tools/executor": "npm:^1.4.7" "@graphql-tools/executor-http": "workspace:^" From 55c410f85d886734eb24d0d2903342eadba75560 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 11 Jun 2025 13:50:48 +0200 Subject: [PATCH 22/44] option --- packages/federation/src/managed-federation.ts | 1 + .../src/federation/supergraph.ts | 2 ++ .../fusion-runtime/src/unifiedGraphManager.ts | 6 ++++ packages/runtime/src/createGatewayRuntime.ts | 1 + packages/runtime/src/types.ts | 33 +++++++++++++++++++ 5 files changed, 43 insertions(+) diff --git a/packages/federation/src/managed-federation.ts b/packages/federation/src/managed-federation.ts index a17361a03..2f921be5c 100644 --- a/packages/federation/src/managed-federation.ts +++ b/packages/federation/src/managed-federation.ts @@ -309,6 +309,7 @@ export async function getStitchedSchemaFromManagedFederation( httpExecutorOpts: options.httpExecutorOpts, onSubschemaConfig: options.onSubschemaConfig, batch: options.batch, + globalObjectIdentification: options.globalObjectIdentification, }), }; } diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 7589136dd..28b3f3422 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -159,6 +159,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ additionalTypeDefs: additionalTypeDefsFromConfig = [], additionalResolvers: additionalResolversFromConfig = [], logger, + globalObjectIdentification, }: UnifiedGraphHandlerOpts): UnifiedGraphHandlerResult { const additionalTypeDefs = [...asArray(additionalTypeDefsFromConfig)]; const additionalResolvers = [...asArray(additionalResolversFromConfig)]; @@ -273,6 +274,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ }, }); }, + globalObjectIdentification, }); const inContextSDK = getInContextSDK( executableUnifiedGraph, diff --git a/packages/fusion-runtime/src/unifiedGraphManager.ts b/packages/fusion-runtime/src/unifiedGraphManager.ts index c8f5b3379..9e31c3f60 100644 --- a/packages/fusion-runtime/src/unifiedGraphManager.ts +++ b/packages/fusion-runtime/src/unifiedGraphManager.ts @@ -68,6 +68,7 @@ export interface UnifiedGraphHandlerOpts { onDelegationPlanHooks?: OnDelegationPlanHook[]; onDelegationStageExecuteHooks?: OnDelegationStageExecuteHook[]; onDelegateHooks?: OnDelegateHook[]; + globalObjectIdentification?: boolean; logger?: Logger; } @@ -107,6 +108,8 @@ export interface UnifiedGraphManagerOptions { instrumentation?: () => Instrumentation | undefined; onUnifiedGraphChange?(newUnifiedGraph: GraphQLSchema): void; + + globalObjectIdentification?: boolean; } export type Instrumentation = { @@ -137,6 +140,7 @@ export class UnifiedGraphManager implements AsyncDisposable { private lastLoadTime?: number; private executor?: Executor; private instrumentation: () => Instrumentation | undefined; + private globalObjectIdentification: boolean; constructor(private opts: UnifiedGraphManagerOptions) { this.batch = opts.batch ?? true; @@ -152,6 +156,7 @@ export class UnifiedGraphManager implements AsyncDisposable { `Starting polling to Supergraph with interval ${millisecondsToStr(opts.pollingInterval)}`, ); } + this.globalObjectIdentification = opts.globalObjectIdentification ?? false; } private ensureUnifiedGraph(): MaybePromise { @@ -310,6 +315,7 @@ export class UnifiedGraphManager implements AsyncDisposable { onDelegationStageExecuteHooks: this.onDelegationStageExecuteHooks, onDelegateHooks: this.opts.onDelegateHooks, logger: this.opts.transportContext?.logger, + globalObjectIdentification: this.globalObjectIdentification, }); const transportExecutorStack = new AsyncDisposableStack(); const onSubgraphExecute = getOnSubgraphExecute({ diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index b6a90662e..42192e682 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -726,6 +726,7 @@ export function createGatewayRuntime< additionalResolvers: config.additionalResolvers as IResolvers[], instrumentation: () => instrumentation, batch: config.__experimental__batchDelegation, + globalObjectIdentification: config.globalObjectIdentification, }); getSchema = () => unifiedGraphManager.getUnifiedGraph(); readinessChecker = () => { diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index ebda1cc81..3f9d40fc7 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -193,6 +193,39 @@ export interface GatewayConfigSupergraph< * If {@link cache} is provided, the fetched {@link supergraph} will be cached setting the TTL to this interval in seconds. */ pollingInterval?: number; + /** + * Add support for GraphQL Global Object Identification Specification by adding a `Node` + * interface, `node(id: ID!): Node` and `nodes(ids: [ID!]!): [Node!]!` fields to the `Query` type. + * + * The `Node` interface will have a `nodeId` (not `id`!) field used as the global identifier. It + * is intentionally not `id` to avoid collisions with existing `id` fields in subgraphs. + * + * ```graphql + * """An object with a globally unique `ID`.""" + * interface Node { + * """ + * A globally unique identifier. Can be used in various places throughout the system to identify this single value. + * """ + * nodeId: ID! + * } + * + * extend type Query { + * """Fetches an object given its globally unique `ID`.""" + * node( + * """The globally unique `ID`.""" + * nodeId: ID! + * ): Node + * """Fetches objects given their globally unique `ID`s.""" + * nodes( + * """The globally unique `ID`s.""" + * nodeIds: [ID!]! + * ): [Node!]! + * } + * ``` + * + * @see https://graphql.org/learn/global-object-identification/ + */ + globalObjectIdentification?: boolean; } export interface GatewayConfigSubgraph< From bff6b2b34d28d5e674244a355ad4749b65b3b69f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 11 Jun 2025 14:37:24 +0200 Subject: [PATCH 23/44] merge resolvers --- packages/fusion-runtime/src/federation/supergraph.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 28b3f3422..c6698e184 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -10,7 +10,7 @@ import type { SubschemaConfig, } from '@graphql-tools/delegate'; import { getStitchedSchemaFromSupergraphSdl } from '@graphql-tools/federation'; -import { mergeTypeDefs } from '@graphql-tools/merge'; +import { mergeResolvers, mergeTypeDefs } from '@graphql-tools/merge'; import { createMergedTypeResolver } from '@graphql-tools/stitch'; import { stitchingDirectives } from '@graphql-tools/stitching-directives'; import { @@ -212,7 +212,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ additionalResolvers, ); // @ts-expect-error - Typings are wrong - opts.resolvers = additionalResolvers; + opts.resolvers = mergeResolvers(opts.resolvers, additionalResolvers); // @ts-expect-error - Typings are wrong opts.inheritResolversFromInterfaces = true; From 2a0ff2797842567748478b5a3ba2503cd73b4dae Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 11 Jun 2025 14:38:18 +0200 Subject: [PATCH 24/44] global object ident e2e --- .../gateway.config.ts | 5 + .../global-object-identification.e2e.ts | 55 ++++++ e2e/global-object-identification/package.json | 7 + .../gateway.config.ts | 43 ----- e2e/relay-additional-resolvers/gw.out | 173 ------------------ e2e/relay-additional-resolvers/id.ts | 20 -- e2e/relay-additional-resolvers/package.json | 9 - .../relay-additional-resolvers.e2e.ts | 53 ------ .../services/posts.ts | 56 ------ .../services/users.ts | 54 ------ yarn.lock | 20 +- 11 files changed, 76 insertions(+), 419 deletions(-) create mode 100644 e2e/global-object-identification/gateway.config.ts create mode 100644 e2e/global-object-identification/global-object-identification.e2e.ts create mode 100644 e2e/global-object-identification/package.json delete mode 100644 e2e/relay-additional-resolvers/gateway.config.ts delete mode 100644 e2e/relay-additional-resolvers/gw.out delete mode 100644 e2e/relay-additional-resolvers/id.ts delete mode 100644 e2e/relay-additional-resolvers/package.json delete mode 100644 e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts delete mode 100644 e2e/relay-additional-resolvers/services/posts.ts delete mode 100644 e2e/relay-additional-resolvers/services/users.ts diff --git a/e2e/global-object-identification/gateway.config.ts b/e2e/global-object-identification/gateway.config.ts new file mode 100644 index 000000000..628bd3582 --- /dev/null +++ b/e2e/global-object-identification/gateway.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@graphql-hive/gateway'; + +export const gatewayConfig = defineConfig({ + globalObjectIdentification: true, +}); diff --git a/e2e/global-object-identification/global-object-identification.e2e.ts b/e2e/global-object-identification/global-object-identification.e2e.ts new file mode 100644 index 000000000..6777e46e9 --- /dev/null +++ b/e2e/global-object-identification/global-object-identification.e2e.ts @@ -0,0 +1,55 @@ +import { createExampleSetup, createTenv } from '@internal/e2e'; +import { toGlobalId } from 'graphql-relay'; +import { expect, it } from 'vitest'; + +const { gateway } = createTenv(__dirname); +const { supergraph, query, result } = createExampleSetup(__dirname); + +it('should execute as usual', async () => { + const { execute } = await gateway({ + supergraph: await supergraph(), + }); + await expect( + execute({ + query, + }), + ).resolves.toEqual(result); +}); + +it('should find objects through node', async () => { + const { execute } = await gateway({ + supergraph: await supergraph(), + }); + await expect( + execute({ + query: /* GraphQL */ ` + query ($nodeId: ID!) { + node(nodeId: $nodeId) { + ... on Product { + nodeId + upc + name + price + weight + } + } + } + `, + variables: { + nodeId: toGlobalId('Product', '2'), + }, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "name": "Couch", + "nodeId": "UHJvZHVjdDoy", + "price": 1299, + "upc": "2", + "weight": 1000, + }, + }, + } + `); +}); diff --git a/e2e/global-object-identification/package.json b/e2e/global-object-identification/package.json new file mode 100644 index 000000000..686278497 --- /dev/null +++ b/e2e/global-object-identification/package.json @@ -0,0 +1,7 @@ +{ + "name": "@e2e/global-object-identification", + "private": true, + "dependencies": { + "graphql-relay": "^0.10.2" + } +} diff --git a/e2e/relay-additional-resolvers/gateway.config.ts b/e2e/relay-additional-resolvers/gateway.config.ts deleted file mode 100644 index 669a03551..000000000 --- a/e2e/relay-additional-resolvers/gateway.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defineConfig } from '@graphql-hive/gateway'; -import { decodeGlobalID } from './id'; - -export const gatewayConfig = defineConfig({ - additionalTypeDefs: /* GraphQL */ ` - type Query { - node(id: ID!): Node - nodes(ids: [ID!]!): [Node]! - } - `, - additionalResolvers: { - Query: { - node(_source: any, args: { id: string }, _context: any, _info: any) { - const { type, localID } = decodeGlobalID(args.id); - return { - __typename: type, - id: localID, - }; - }, - nodes(_source: any, args: { ids: string[] }, _context: any, _info: any) { - return args.ids.map((id) => { - const { type, localID } = decodeGlobalID(id); - return { - __typename: type, - id: localID, - }; - }); - }, - _entities: (_: any, { representations }: any) => { - return representations.map((ref) => { - // Since all our entities just need id, we can return the reference as is - // The __typename is already included in the reference - return ref; - }); - }, - }, - Node: { - __resolveType(source: { __typename: string }) { - return source.__typename; - }, - }, - }, -}); diff --git a/e2e/relay-additional-resolvers/gw.out b/e2e/relay-additional-resolvers/gw.out deleted file mode 100644 index 16295a523..000000000 --- a/e2e/relay-additional-resolvers/gw.out +++ /dev/null @@ -1,173 +0,0 @@ -[2025-05-26T14:55:36.564Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql  -[2025-05-26T14:55:36.566Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql  -[2025-05-26T14:55:36.566Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql for changes  -[2025-05-26T14:55:36.569Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fshFt7ol/supergraph.graphql  -[2025-05-26T14:55:36.579Z] INFO  Listening on http://localhost:60575  -[2025-05-26T14:56:42.380Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T14:56:42.387Z] INFO  Loaded config  -[2025-05-26T14:56:42.387Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql  -[2025-05-26T14:56:42.388Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql  -[2025-05-26T14:56:42.388Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql for changes  -[2025-05-26T14:56:42.390Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsdxRzJ4/supergraph.graphql  -[2025-05-26T14:56:42.397Z] INFO  Listening on http://localhost:60714  -[2025-05-26T14:56:42.610Z] ERROR  Error: Query.node defined in resolvers, but not in schema - at addResolversToSchema (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/schema/esm/addResolversToSchema.js:80:39) - at stitchSchemas (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/stitch/src/stitchSchemas.ts:136:12) - at getStitchedSchemaFromSupergraphSdl (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/federation/src/supergraph.ts:1524:26) - at UnifiedGraphManager.handleFederationSupergraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/federation/supergraph.ts:188:32) - at UnifiedGraphManager.handleLoadedUnifiedGraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:302:16) - at (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:388:14)  -[2025-05-26T14:56:42.614Z] ERROR  Error: Query.node defined in resolvers, but not in schema - at addResolversToSchema (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/schema/esm/addResolversToSchema.js:80:39) - at stitchSchemas (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/stitch/src/stitchSchemas.ts:136:12) - at getStitchedSchemaFromSupergraphSdl (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/federation/src/supergraph.ts:1524:26) - at UnifiedGraphManager.handleFederationSupergraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/federation/supergraph.ts:188:32) - at UnifiedGraphManager.handleLoadedUnifiedGraph (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:302:16) - at (/Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/packages/fusion-runtime/src/unifiedGraphManager.ts:388:14)  -[2025-05-26T14:57:52.746Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T14:57:52.753Z] INFO  Loaded config  -[2025-05-26T14:57:52.753Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql  -[2025-05-26T14:57:52.754Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql  -[2025-05-26T14:57:52.754Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql for changes  -[2025-05-26T14:57:52.756Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsYpodCQ/supergraph.graphql  -[2025-05-26T14:57:52.762Z] INFO  Listening on http://localhost:60826  -[2025-05-26T14:57:53.026Z] ERROR  Error: Cannot query field "node" on type "Query". - at Object.node (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts:13:15) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:351:24) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) - at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:37 - at Promise.then (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:34:40) - at handleMaybePromise (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:10:33) - at executeImpl (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:12) - at execute (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:49:12) - at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/normalizedExecutor.js:13:37 { - path: [ 'node' ], - locations: [ { line: 3, column: 11 } ], - extensions: [Object: null prototype] {} -}  -[2025-05-26T15:01:11.667Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:01:11.674Z] INFO  Loaded config  -[2025-05-26T15:01:11.674Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql  -[2025-05-26T15:01:11.675Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql  -[2025-05-26T15:01:11.675Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql for changes  -[2025-05-26T15:01:11.678Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsJCufQu/supergraph.graphql  -[2025-05-26T15:01:11.685Z] INFO  Listening on http://localhost:61090  -[2025-05-26T15:02:08.930Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:02:08.939Z] INFO  Loaded config  -[2025-05-26T15:02:08.939Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql  -[2025-05-26T15:02:08.940Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql  -[2025-05-26T15:02:08.940Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql for changes  -[2025-05-26T15:02:08.942Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fs9Hcusc/supergraph.graphql  -[2025-05-26T15:02:08.948Z] INFO  Listening on http://localhost:61194  -[2025-05-26T15:02:30.927Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:02:30.935Z] INFO  Loaded config  -[2025-05-26T15:02:30.935Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql  -[2025-05-26T15:02:30.936Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql  -[2025-05-26T15:02:30.936Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql for changes  -[2025-05-26T15:02:30.938Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfZc8xM/supergraph.graphql  -[2025-05-26T15:02:30.944Z] INFO  Listening on http://localhost:61257  -{ id: 'VXNlcjp1c2VyLTI=' } -[2025-05-26T15:02:31.187Z] ERROR  Error: Cannot query field "node" on type "Query". - at Object.node (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts:14:15) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:351:24) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) - at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:37 - at Promise.then (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:34:40) - at handleMaybePromise (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@whatwg-node/promise-helpers/esm/index.js:10:33) - at executeImpl (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:64:12) - at execute (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:49:12) - at file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/normalizedExecutor.js:13:37 { - path: [ 'node' ], - locations: [ { line: 3, column: 11 } ], - extensions: [Object: null prototype] {} -}  -[2025-05-26T15:04:08.181Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:04:08.191Z] INFO  Loaded config  -[2025-05-26T15:04:08.191Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql  -[2025-05-26T15:04:08.191Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql  -[2025-05-26T15:04:08.191Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql for changes  -[2025-05-26T15:04:08.194Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fstEv1aL/supergraph.graphql  -[2025-05-26T15:04:08.200Z] INFO  Listening on http://localhost:61412  -[2025-05-26T15:04:08.458Z] ERROR  Error: Cannot return null for non-nullable field User.name. - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) - at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) - at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { - path: [ 'node', 'name' ], - locations: [ { line: 5, column: 15 } ], - extensions: [Object: null prototype] {} -}  -[2025-05-26T15:05:46.728Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:05:46.738Z] INFO  Loaded config  -[2025-05-26T15:05:46.738Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql  -[2025-05-26T15:05:46.739Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql  -[2025-05-26T15:05:46.739Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql for changes  -[2025-05-26T15:05:46.741Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fskLgGRP/supergraph.graphql  -[2025-05-26T15:05:46.747Z] INFO  Listening on http://localhost:61572  -[2025-05-26T15:05:47.016Z] ERROR  Error: Cannot return null for non-nullable field User.name. - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) - at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) - at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { - path: [ 'node', 'name' ], - locations: [ { line: 5, column: 15 } ], - extensions: [Object: null prototype] {} -}  -[2025-05-26T15:09:42.499Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:09:42.508Z] INFO  Loaded config  -[2025-05-26T15:09:42.508Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql  -[2025-05-26T15:09:42.509Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql  -[2025-05-26T15:09:42.509Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql for changes  -[2025-05-26T15:09:42.511Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsOwxhFd/supergraph.graphql  -[2025-05-26T15:09:42.517Z] INFO  Listening on http://localhost:61887  -[2025-05-26T15:09:42.786Z] ERROR  Error: Cannot return null for non-nullable field User.name. - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) - at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) - at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { - path: [ 'node', 'name' ], - locations: [ { line: 5, column: 15 } ], - extensions: [Object: null prototype] {} -}  -[2025-05-26T15:12:09.330Z] INFO  Found default config file /Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/e2e/relay-additional-resolvers/gateway.config.ts  -[2025-05-26T15:12:09.342Z] INFO  Loaded config  -[2025-05-26T15:12:09.343Z] INFO  Supergraph will be loaded from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql  -[2025-05-26T15:12:09.343Z] INFO  Reading supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql  -[2025-05-26T15:12:09.343Z] INFO  Watching /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql for changes  -[2025-05-26T15:12:09.346Z] INFO  Serving local supergraph from /var/folders/x_/ly8bj5d97ks505gzsxc49_vr0000gn/T/hive-gateway_e2e_fsfAkQmK/supergraph.graphql  -[2025-05-26T15:12:09.352Z] INFO  Listening on http://localhost:62107  -[2025-05-26T15:12:09.626Z] ERROR  Error: Cannot return null for non-nullable field User.name. - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:467:19) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at collectAndExecuteSubfields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:736:23) - at completeObjectValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:726:12) - at completeAbstractValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:675:12) - at completeValue (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:487:16) - at executeField (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:357:25) - at executeFields (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:296:28) - at executeOperation (file:///Users/enisdenjo/Develop/src/github.com/graphql-hive/gateway/node_modules/@graphql-tools/executor/esm/execution/execute.js:260:18) { - path: [ 'node', 'name' ], - locations: [ { line: 5, column: 15 } ], - extensions: [Object: null prototype] {} -}  diff --git a/e2e/relay-additional-resolvers/id.ts b/e2e/relay-additional-resolvers/id.ts deleted file mode 100644 index 79c91eb09..000000000 --- a/e2e/relay-additional-resolvers/id.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Encodes a type and local ID into a global ID - * @param type - The type of the entity (e.g., "Person", "Story") - * @param localID - The local ID of the entity - * @returns Base64 encoded global ID - */ -export function encodeGlobalID(type: string, localID: string) { - return Buffer.from(JSON.stringify([type, localID])).toString('base64'); -} - -/** - * Decodes a global ID into its type and local ID components - * @param globalID - The base64 encoded global ID - * @returns Object containing type and local ID - */ -export function decodeGlobalID(globalID: string) { - const decoded = Buffer.from(globalID, 'base64').toString('utf-8'); - const [type, localID] = JSON.parse(decoded) as [string, string]; - return { type, localID }; -} diff --git a/e2e/relay-additional-resolvers/package.json b/e2e/relay-additional-resolvers/package.json deleted file mode 100644 index e5b4dfb9e..000000000 --- a/e2e/relay-additional-resolvers/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@e2e/relay-additional-resolvers", - "private": true, - "dependencies": { - "@apollo/subgraph": "^2.10.0", - "graphql": "^16.9.0", - "graphql-sse": "^2.5.3" - } -} diff --git a/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts b/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts deleted file mode 100644 index b9e90fb2e..000000000 --- a/e2e/relay-additional-resolvers/relay-additional-resolvers.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createTenv } from '@internal/e2e'; -import { expect, it } from 'vitest'; -import { encodeGlobalID } from './id'; - -const { gateway, service } = createTenv(__dirname); - -it('should resolve the data behind the node', async () => { - const { execute } = await gateway({ - pipeLogs: 'gw.out', - supergraph: { - with: 'apollo', - services: [await service('users'), await service('posts')], - }, - }); - - await expect( - execute({ - query: /* GraphQL */ ` - query ($id: ID!) { - node(id: $id) { - ... on User { - name - posts { - title - content - } - } - } - } - `, - variables: { - id: encodeGlobalID('User', 'user-2'), - }, - }), - ).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "extensions": { - "code": "GRAPHQL_VALIDATION_FAILED", - }, - "locations": [ - { - "column": 11, - "line": 3, - }, - ], - "message": "Cannot query field "node" on type "Query".", - }, - ], - } - `); -}); diff --git a/e2e/relay-additional-resolvers/services/posts.ts b/e2e/relay-additional-resolvers/services/posts.ts deleted file mode 100644 index fd3557053..000000000 --- a/e2e/relay-additional-resolvers/services/posts.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createServer } from 'http'; -import { buildSubgraphSchema } from '@apollo/subgraph'; -import { Opts } from '@internal/testing'; -import { parse } from 'graphql'; -import { createYoga } from 'graphql-yoga'; - -const posts: { id: string; title: string; content: string }[] = [ - { - id: 'post-1', - title: 'Hello world', - content: 'This is a post', - }, - { - id: 'post-2', - title: 'Hello again', - content: 'This is another post', - }, - { - id: 'post-3', - title: 'Hello again again', - content: 'This is another post again', - }, -]; - -const typeDefs = parse(/* GraphQL */ ` - type Query { - hello: String! - } - interface Node { - id: ID! - } - type Post implements Node @key(fields: "id") { - id: ID! - title: String! - content: String! - } -`); - -const resolvers = { - Query: { - hello: () => 'world', - }, - Post: { - __resolveReference(post: { id: string }) { - return posts.find((p) => p.id === post.id); - }, - }, -}; - -const yoga = createYoga({ - schema: buildSubgraphSchema([{ typeDefs, resolvers }]), -}); - -const opts = Opts(process.argv); - -createServer(yoga).listen(opts.getServicePort('posts')); diff --git a/e2e/relay-additional-resolvers/services/users.ts b/e2e/relay-additional-resolvers/services/users.ts deleted file mode 100644 index dbcf214ff..000000000 --- a/e2e/relay-additional-resolvers/services/users.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createServer } from 'http'; -import { buildSubgraphSchema } from '@apollo/subgraph'; -import { Opts } from '@internal/testing'; -import { parse } from 'graphql'; -import { createYoga } from 'graphql-yoga'; - -const users: { id: string; name: string; posts: { id: string }[] }[] = [ - { - id: 'user-1', - name: 'John Doe', - posts: [{ id: 'post-2' }], - }, - { - id: 'user-2', - name: 'Jane Doe', - posts: [{ id: 'post-3' }, { id: 'post-1' }], - }, -]; - -const typeDefs = parse(/* GraphQL */ ` - type Query { - hello: String! - } - interface Node { - id: ID! - } - type User implements Node @key(fields: "id") { - id: ID! - name: String! - posts: [Post!]! - } - type Post implements Node { - id: ID! - } -`); - -const resolvers = { - Query: { - hello: () => 'world', - }, - User: { - __resolveReference(user: { id: string }) { - return users.find((u) => u.id === user.id); - }, - }, -}; - -const yoga = createYoga({ - schema: buildSubgraphSchema([{ typeDefs, resolvers }]), -}); - -const opts = Opts(process.argv); - -createServer(yoga).listen(opts.getServicePort('users')); diff --git a/yarn.lock b/yarn.lock index 2b86aefea..70c38ad14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -397,7 +397,7 @@ __metadata: languageName: node linkType: hard -"@apollo/subgraph@npm:^2.10.0, @apollo/subgraph@npm:^2.11.0": +"@apollo/subgraph@npm:^2.11.0": version: 2.11.0 resolution: "@apollo/subgraph@npm:2.11.0" dependencies: @@ -3062,6 +3062,14 @@ __metadata: languageName: unknown linkType: soft +"@e2e/global-object-identification@workspace:e2e/global-object-identification": + version: 0.0.0-use.local + resolution: "@e2e/global-object-identification@workspace:e2e/global-object-identification" + dependencies: + graphql-relay: "npm:^0.10.2" + languageName: unknown + linkType: soft + "@e2e/graceful-shutdown@workspace:e2e/graceful-shutdown": version: 0.0.0-use.local resolution: "@e2e/graceful-shutdown@workspace:e2e/graceful-shutdown" @@ -3356,16 +3364,6 @@ __metadata: languageName: unknown linkType: soft -"@e2e/relay-additional-resolvers@workspace:e2e/relay-additional-resolvers": - version: 0.0.0-use.local - resolution: "@e2e/relay-additional-resolvers@workspace:e2e/relay-additional-resolvers" - dependencies: - "@apollo/subgraph": "npm:^2.10.0" - graphql: "npm:^16.9.0" - graphql-sse: "npm:^2.5.3" - languageName: unknown - linkType: soft - "@e2e/retry-timeout@workspace:e2e/retry-timeout": version: 0.0.0-use.local resolution: "@e2e/retry-timeout@workspace:e2e/retry-timeout" From c77d0ea27c55be1f6a6ce02efec5f2854cf757b4 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 11 Jun 2025 17:47:32 +0200 Subject: [PATCH 25/44] specify return type --- packages/federation/src/globalObjectIdentification.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 44749a738..315804925 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -4,6 +4,7 @@ import { IResolvers } from '@graphql-tools/utils'; import { DefinitionNode, FieldDefinitionNode, + GraphQLObjectType, InterfaceTypeDefinitionNode, Kind, ObjectTypeExtensionNode, @@ -198,6 +199,9 @@ export function createResolvers({ info, context, schema: type.subschema, + returnType: type.subschema.schema.getType(typeName) as + | GraphQLObjectType + | undefined, // shouldnt ever be undefined selectionSet: undefined, // selectionSet is not needed here key: { ...keyFields, __typename: typeName }, // we already have all the necessary keys valuesFromResults: (results) => From c3c5449a1ef56ee324e2f3db8093b2de799c1889 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 10:24:55 +0200 Subject: [PATCH 26/44] resolve from other subgraphs too --- .../tests/globalObjectIdentification.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/federation/tests/globalObjectIdentification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts index 14cc6a7ef..67588426a 100644 --- a/packages/federation/tests/globalObjectIdentification.test.ts +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -15,6 +15,12 @@ describe('Global Object Identification', () => { email: 'john@doe.com', }, ], + auth: [ + { + id: 'a1', + isVerified: true, + }, + ], people: [ { firstName: 'John', @@ -50,6 +56,20 @@ describe('Global Object Identification', () => { }, }); + const auth = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Account @key(fields: "id") { + id: ID! + isVerified: Boolean! + } + `), + resolvers: { + Account: { + __resolveReference: (ref) => data.auth.find((a) => a.id === ref.id), + }, + }, + }); + const people = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` type Query { @@ -115,11 +135,53 @@ describe('Global Object Identification', () => { `); }); + it('should resolve without node as usual', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + accounts, + auth, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + accounts { + id + name + email + isVerified + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "accounts": [ + { + "email": "john@doe.com", + "id": "a1", + "isVerified": true, + "name": "John Doe", + }, + ], + }, + } + `); + }); + it('should resolve single field key object from globally unique node', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, localSchemas: { accounts, + auth, }, }); @@ -135,6 +197,7 @@ describe('Global Object Identification', () => { id name email + isVerified } } } @@ -147,6 +210,7 @@ describe('Global Object Identification', () => { "node": { "email": "john@doe.com", "id": "a1", + "isVerified": true, "name": "John Doe", "nodeId": "QWNjb3VudDphMQ==", }, From d9d5509da191439847dd12ceda5dba24377b3e10 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 13:46:32 +0200 Subject: [PATCH 27/44] correct returntype --- packages/batch-delegate/src/getLoader.ts | 5 +++- .../src/globalObjectIdentification.ts | 27 ++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/batch-delegate/src/getLoader.ts b/packages/batch-delegate/src/getLoader.ts index 655be63b7..b5558f225 100644 --- a/packages/batch-delegate/src/getLoader.ts +++ b/packages/batch-delegate/src/getLoader.ts @@ -21,7 +21,10 @@ function createBatchFn(options: BatchDelegateOptions) { .then(() => delegateToSchema({ returnType: new GraphQLList( - getNamedType(options.returnType || options.info.returnType), + getNamedType( + // options.returnType || // if the returnType is provided by options, it'll override this property because of the spread below. it was like this since forever, so lets keep it for backwards compatibility + options.info.returnType, + ), ), onLocatedError: (originalError) => { if (originalError.path == null) { diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 315804925..8b6d30d42 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -1,9 +1,10 @@ import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; -import { SubschemaConfig } from '@graphql-tools/delegate'; +import { StitchingInfo, SubschemaConfig } from '@graphql-tools/delegate'; import { IResolvers } from '@graphql-tools/utils'; import { DefinitionNode, FieldDefinitionNode, + GraphQLList, GraphQLObjectType, InterfaceTypeDefinitionNode, Kind, @@ -171,6 +172,18 @@ export function createResolvers({ ), Query: { node(_source, { nodeId }, context, info) { + const stitchingInfo = info.schema.extensions?.['stitchingInfo'] as + | StitchingInfo + | undefined; + if (!stitchingInfo) { + return null; // no stitching info, something went wrong // TODO: throw instead? + } + + // we must use otherwise different schema + const types = getDistinctResolvableTypes( + stitchingInfo.subschemaMap.values(), + ); + const { id: idOrFields, type: typeName } = fromGlobalId(nodeId); const type = types.find((t) => t.typeName === typeName); if (!type) { @@ -199,16 +212,12 @@ export function createResolvers({ info, context, schema: type.subschema, - returnType: type.subschema.schema.getType(typeName) as - | GraphQLObjectType - | undefined, // shouldnt ever be undefined + returnType: new GraphQLList( + // wont ever be undefined, we ensured the subschema has the type above + type.subschema.schema.getType(typeName) as GraphQLObjectType, + ), selectionSet: undefined, // selectionSet is not needed here key: { ...keyFields, __typename: typeName }, // we already have all the necessary keys - valuesFromResults: (results) => - // add the nodeId field to the results - results.map((r: any) => - !r ? null : { ...r, [nodeIdField]: nodeId }, - ), }); }, }, From 6f0c11a5189af7d18b48156ff81facddba2c34f8 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 13:52:05 +0200 Subject: [PATCH 28/44] changeseet --- .changeset/rare-pants-develop.md | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .changeset/rare-pants-develop.md diff --git a/.changeset/rare-pants-develop.md b/.changeset/rare-pants-develop.md new file mode 100644 index 000000000..24ffba25b --- /dev/null +++ b/.changeset/rare-pants-develop.md @@ -0,0 +1,47 @@ +--- +'@graphql-mesh/fusion-runtime': minor +'@graphql-tools/federation': minor +'@graphql-hive/gateway-runtime': minor +--- + +Automatic Global Object Identification + +Setting the `globalObjectIdentification` option to true will automatically implement the +GraphQL Global Object Identification Specification by adding a `Node` interface, `node(id: ID!): Node` +and `nodes(ids: [ID!]!): [Node!]!` fields to the `Query` type. + +The `Node` interface will have a `nodeId` (not `id`!) field used as the global identifier. It +is intentionally not `id` to avoid collisions with existing `id` fields in subgraphs. + +```graphql +""" +An object with a globally unique `ID`. +""" +interface Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! +} + +extend type Query { + """ + Fetches an object given its globally unique `ID`. + """ + node( + """ + The globally unique `ID`. + """ + nodeId: ID! + ): Node + """ + Fetches objects given their globally unique `ID`s. + """ + nodes( + """ + The globally unique `ID`s. + """ + nodeIds: [ID!]! + ): [Node!]! +} +``` From 64f381761a95e50cd3b178bbd9df3a68a2e6da03 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Jun 2025 11:58:53 +0000 Subject: [PATCH 29/44] chore(dependencies): updated changesets for modified dependencies --- .changeset/@graphql-tools_federation-1232-dependencies.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/@graphql-tools_federation-1232-dependencies.md diff --git a/.changeset/@graphql-tools_federation-1232-dependencies.md b/.changeset/@graphql-tools_federation-1232-dependencies.md new file mode 100644 index 000000000..5dceff497 --- /dev/null +++ b/.changeset/@graphql-tools_federation-1232-dependencies.md @@ -0,0 +1,8 @@ +--- +'@graphql-tools/federation': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-tools/batch-delegate@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-tools/batch-delegate/v/workspace:^) (to `dependencies`) +- Added dependency [`graphql-relay@^0.10.2` ↗︎](https://www.npmjs.com/package/graphql-relay/v/0.10.2) (to `dependencies`) From b35033b78fdcef5cd600346361bd3a389447297c Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 14:36:50 +0200 Subject: [PATCH 30/44] resolve func --- .../src/globalObjectIdentification.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 8b6d30d42..c32b73706 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -153,18 +153,20 @@ export function createResolvers({ (resolvers, { typeName, keyFieldNames }) => ({ ...resolvers, [typeName]: { - [nodeIdField](source) { - if (keyFieldNames.length === 1) { - // single field key - return toGlobalId(typeName, source[keyFieldNames[0]!]); - } - // multiple fields key - const keyFields: Record = {}; - for (const fieldName of keyFieldNames) { - // loop is faster than reduce - keyFields[fieldName] = source[fieldName]; - } - return toGlobalId(typeName, JSON.stringify(keyFields)); + [nodeIdField]: { + resolve(source) { + if (keyFieldNames.length === 1) { + // single field key + return toGlobalId(typeName, source[keyFieldNames[0]!]); + } + // multiple fields key + const keyFields: Record = {}; + for (const fieldName of keyFieldNames) { + // loop is faster than reduce + keyFields[fieldName] = source[fieldName]; + } + return toGlobalId(typeName, JSON.stringify(keyFields)); + }, }, }, }), From b5cbb77dda9dd265d855bec9eb3e347080f9c675 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 14:37:34 +0200 Subject: [PATCH 31/44] node id without dependant fields --- .../src/globalObjectIdentification.ts | 1 + .../tests/globalObjectIdentification.test.ts | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index c32b73706..108107900 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -154,6 +154,7 @@ export function createResolvers({ ...resolvers, [typeName]: { [nodeIdField]: { + selectionSet: `{ ${keyFieldNames.join(' ')} }`, resolve(source) { if (keyFieldNames.length === 1) { // single field key diff --git a/packages/federation/tests/globalObjectIdentification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts index 67588426a..125381bf1 100644 --- a/packages/federation/tests/globalObjectIdentification.test.ts +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -219,6 +219,40 @@ describe('Global Object Identification', () => { `); }); + it('should resolve node id without requesting key fields in client query', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + accounts, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + accounts { + nodeId + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "accounts": [ + { + "nodeId": "QWNjb3VudDphMQ==", + }, + ], + }, + } + `); + }); + it('should not resolve single field key object from globally unique node when doesnt exist', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, @@ -292,6 +326,43 @@ describe('Global Object Identification', () => { `); }); + it('should resolve node id without requesting key fields in client query', async () => { + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + people, + }, + }); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + people { + nodeId + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "people": [ + { + "nodeId": "UGVyc29uOnsiZmlyc3ROYW1lIjoiSm9obiIsImxhc3ROYW1lIjoiRG9lIn0=", + }, + { + "nodeId": "UGVyc29uOnsiZmlyc3ROYW1lIjoiSmFuZSIsImxhc3ROYW1lIjoiRG9lIn0=", + }, + ], + }, + } + `); + }); + it('should resolve node id from object', async () => { const schema = await getStitchedSchemaFromLocalSchemas({ globalObjectIdentification: true, From 44f4a9ae6ba0cf06fe4c985c6b597e99ad1c2c3d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 14:40:23 +0200 Subject: [PATCH 32/44] add graphl --- e2e/global-object-identification/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/e2e/global-object-identification/package.json b/e2e/global-object-identification/package.json index 686278497..15748fd9c 100644 --- a/e2e/global-object-identification/package.json +++ b/e2e/global-object-identification/package.json @@ -2,6 +2,7 @@ "name": "@e2e/global-object-identification", "private": true, "dependencies": { + "graphql": "^16.11.0", "graphql-relay": "^0.10.2" } } diff --git a/yarn.lock b/yarn.lock index 70c38ad14..d5c4c422c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3066,6 +3066,7 @@ __metadata: version: 0.0.0-use.local resolution: "@e2e/global-object-identification@workspace:e2e/global-object-identification" dependencies: + graphql: "npm:^16.11.0" graphql-relay: "npm:^0.10.2" languageName: unknown linkType: soft From db46408d83f063eb88770bd004f35c7a76416718 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 14:48:13 +0200 Subject: [PATCH 33/44] no generator --- .../src/globalObjectIdentification.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 108107900..e04131398 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -147,7 +147,7 @@ export function createResolvers({ nodeIdField, subschemas, }: GlobalObjectIdentificationOptions): IResolvers { - const types = getDistinctResolvableTypes(subschemas).toArray(); + const types = getDistinctResolvableTypes(subschemas); return { ...types.reduce( (resolvers, { typeName, keyFieldNames }) => ({ @@ -227,8 +227,18 @@ export function createResolvers({ }; } -function* getDistinctResolvableTypes(subschemas: Iterable) { - const yieldedTypes = new Set(); +interface DistinctResolvableType { + typeName: string; + subschema: SubschemaConfig; + merge: MergedTypeConfigFromEntities; + keyFieldNames: string[]; +} + +function getDistinctResolvableTypes( + subschemas: Iterable, +): DistinctResolvableType[] { + const visitedTypeNames = new Set(); + const types: DistinctResolvableType[] = []; for (const subschema of subschemas) { // TODO: respect canonical types for (const [typeName, merge] of Object.entries(subschema.merge || {}) @@ -240,7 +250,7 @@ function* getDistinctResolvableTypes(subschemas: Iterable) { // sort by shortest keys first ([, a], [, b]) => a.selectionSet!.length - b.selectionSet!.length, )) { - if (yieldedTypes.has(typeName)) { + if (visitedTypeNames.has(typeName)) { // already yielded this type, all types can only have one resolution continue; } @@ -272,13 +282,14 @@ function* getDistinctResolvableTypes(subschemas: Iterable) { } // what we're left in the "key" are simple field(s) like "id" or "email" - yieldedTypes.add(typeName); - yield { + visitedTypeNames.add(typeName); + types.push({ typeName, subschema, merge: merge as MergedTypeConfigFromEntities, keyFieldNames: key.trim().split(/\s+/), - }; + }); } } + return types; } From d8658039f275a2d55e85e8cf983b29eb9f8814c2 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 15:28:39 +0200 Subject: [PATCH 34/44] properly merge resolvers --- packages/fusion-runtime/src/federation/supergraph.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index c6698e184..448953685 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -10,7 +10,7 @@ import type { SubschemaConfig, } from '@graphql-tools/delegate'; import { getStitchedSchemaFromSupergraphSdl } from '@graphql-tools/federation'; -import { mergeResolvers, mergeTypeDefs } from '@graphql-tools/merge'; +import { mergeTypeDefs } from '@graphql-tools/merge'; import { createMergedTypeResolver } from '@graphql-tools/stitch'; import { stitchingDirectives } from '@graphql-tools/stitching-directives'; import { @@ -212,7 +212,11 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ additionalResolvers, ); // @ts-expect-error - Typings are wrong - opts.resolvers = mergeResolvers(opts.resolvers, additionalResolvers); + opts.resolvers = + opts.resolvers && additionalResolvers + ? [opts.resolvers, ...additionalResolvers] + : additionalResolvers; + // opts.resolvers = additionalResolvers; // @ts-expect-error - Typings are wrong opts.inheritResolversFromInterfaces = true; From bd7834d4aa93266d878c9766b3ad0c784e747f13 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 15:42:31 +0200 Subject: [PATCH 35/44] simpler lol --- packages/fusion-runtime/src/federation/supergraph.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 448953685..35c42aeef 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -212,10 +212,9 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ additionalResolvers, ); // @ts-expect-error - Typings are wrong - opts.resolvers = - opts.resolvers && additionalResolvers - ? [opts.resolvers, ...additionalResolvers] - : additionalResolvers; + opts.resolvers = opts.resolvers + ? [opts.resolvers, ...additionalResolvers] + : additionalResolvers; // opts.resolvers = additionalResolvers; // @ts-expect-error - Typings are wrong opts.inheritResolversFromInterfaces = true; From e7e9e644d114ebee38dfcd99d6af5b08ae8cef54 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 18:06:46 +0200 Subject: [PATCH 36/44] improve tests --- .../tests/globalObjectIdentification.test.ts | 631 +++++++++++------- 1 file changed, 373 insertions(+), 258 deletions(-) diff --git a/packages/federation/tests/globalObjectIdentification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts index 125381bf1..cd7d99999 100644 --- a/packages/federation/tests/globalObjectIdentification.test.ts +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -1,106 +1,14 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; import { normalizedExecutor } from '@graphql-tools/executor'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; -import { parse } from 'graphql'; +import { parse, validate } from 'graphql'; import { toGlobalId } from 'graphql-relay'; import { describe, expect, it } from 'vitest'; import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; describe('Global Object Identification', () => { - const data = { - accounts: [ - { - id: 'a1', - name: 'John Doe', - email: 'john@doe.com', - }, - ], - auth: [ - { - id: 'a1', - isVerified: true, - }, - ], - people: [ - { - firstName: 'John', - lastName: 'Doe', - email: 'john@doe.com', - }, - { - firstName: 'Jane', - lastName: 'Doe', - email: 'jane@doe.com', - }, - ], - }; - - const accounts = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - accounts: [Account!]! - } - type Account @key(fields: "id") { - id: ID! - name: String! - email: String! - } - `), - resolvers: { - Query: { - accounts: () => data.accounts, - }, - Account: { - __resolveReference: (ref) => data.accounts.find((a) => a.id === ref.id), - }, - }, - }); - - const auth = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Account @key(fields: "id") { - id: ID! - isVerified: Boolean! - } - `), - resolvers: { - Account: { - __resolveReference: (ref) => data.auth.find((a) => a.id === ref.id), - }, - }, - }); - - const people = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - people: [Person!]! - } - type Person @key(fields: "firstName lastName") { - firstName: String! - lastName: String! - email: String! - } - `), - resolvers: { - Query: { - people: () => data.people, - }, - Person: { - __resolveReference: (ref) => - data.people.find( - (a) => a.firstName === ref.firstName && a.lastName === ref.lastName, - ), - }, - }, - }); - it('should generate stitched schema with node interface', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - accounts, - }, - }); + const { schema } = await getSchema(); expect(printSchemaWithDirectives(schema)).toMatchInlineSnapshot(` "schema { @@ -108,7 +16,9 @@ describe('Global Object Identification', () => { } type Query { - accounts: [Account!]! + feed: [Story!]! + people: [Person!]! + organizations: [Organization!]! """Fetches an object given its globally unique \`ID\`.""" node( """The globally unique \`ID\`.""" @@ -116,10 +26,36 @@ describe('Global Object Identification', () => { ): Node } - type Account implements Node { + interface Actor { id: ID! name: String! - email: String! + } + + type Organization implements Actor & Node { + id: ID! + name: String! + foundingDate: String! + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + } + + type Person implements Actor & Node { + id: ID! + name: String! + dateOfBirth: String! + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + } + + type Story implements Node { + title: String! + publishedAt: String! + actor: Actor! + content: String! """ A globally unique identifier. Can be used in various places throughout the system to identify this single value. """ @@ -136,39 +72,55 @@ describe('Global Object Identification', () => { }); it('should resolve without node as usual', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - accounts, - auth, - }, - }); + const { execute } = await getSchema(); await expect( - Promise.resolve( - normalizedExecutor({ - schema, - document: parse(/* GraphQL */ ` - { - accounts { - id + execute({ + query: /* GraphQL */ ` + { + feed { + title + content + actor { name - email - isVerified + ... on Person { + dateOfBirth + } + ... on Organization { + foundingDate + } } } - `), - }), - ), + } + `, + }), ).resolves.toMatchInlineSnapshot(` { "data": { - "accounts": [ + "feed": [ { - "email": "john@doe.com", - "id": "a1", - "isVerified": true, - "name": "John Doe", + "actor": { + "dateOfBirth": "2001-01-01", + "name": "John Doe", + }, + "content": "Lorem ipsum dolor sit amet.", + "title": "Personal Story 1", + }, + { + "actor": { + "dateOfBirth": "2002-02-02", + "name": "Jane Doe", + }, + "content": "Lorem ipsum dolor sit amet.", + "title": "Personal Story 2", + }, + { + "actor": { + "foundingDate": "1993-03-03", + "name": "Foo Inc.", + }, + "content": "Lorem ipsum dolor sit amet.", + "title": "Corporate Story 3", }, ], }, @@ -176,200 +128,161 @@ describe('Global Object Identification', () => { `); }); - it('should resolve single field key object from globally unique node', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - accounts, - auth, - }, - }); + it('should resolve single field key object', async () => { + const { data, execute } = await getSchema(); await expect( - Promise.resolve( - normalizedExecutor({ - schema, - document: parse(/* GraphQL */ ` + execute({ + query: /* GraphQL */ ` { - node(nodeId: "${toGlobalId('Account', 'a1')}") { + node(nodeId: "${toGlobalId('Person', data.people[0].id)}") { nodeId - ... on Account { - id + ... on Person { name - email - isVerified + dateOfBirth } } } - `), - }), - ), + `, + }), ).resolves.toMatchInlineSnapshot(` { "data": { "node": { - "email": "john@doe.com", - "id": "a1", - "isVerified": true, + "dateOfBirth": "2001-01-01", "name": "John Doe", - "nodeId": "QWNjb3VudDphMQ==", + "nodeId": "UGVyc29uOnAx", }, }, } `); }); - it('should resolve node id without requesting key fields in client query', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - accounts, - }, - }); + it('should resolve multiple fields key object', async () => { + const { data, execute } = await getSchema(); await expect( - Promise.resolve( - normalizedExecutor({ - schema, - document: parse(/* GraphQL */ ` - { - accounts { - nodeId - } + execute({ + query: /* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Story', JSON.stringify(data.stories[1]))}") { + ... on Story { + nodeId + title + content } - `), - }), - ), + } + } + `, + }), ).resolves.toMatchInlineSnapshot(` { "data": { - "accounts": [ - { - "nodeId": "QWNjb3VudDphMQ==", - }, - ], + "node": { + "content": "Lorem ipsum dolor sit amet.", + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IlBlcnNvbmFsIFN0b3J5IDIiLCJwdWJsaXNoZWRBdCI6IjIwMTItMDItMDIifQ==", + "title": "Personal Story 2", + }, }, } `); }); - it('should not resolve single field key object from globally unique node when doesnt exist', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - accounts, - }, - }); + it('should resolve node id from object', async () => { + const { execute } = await getSchema(); await expect( - Promise.resolve( - normalizedExecutor({ - schema, - document: parse(/* GraphQL */ ` - { - node(nodeId: "${toGlobalId('Account', 'dontexist1')}") { - nodeId - ... on Account { - id + execute({ + query: /* GraphQL */ ` + { + people { + nodeId # we omit the "id" key field making sure it's resolved internally name - email + dateOfBirth } } - } - `), - }), - ), + `, + }), ).resolves.toMatchInlineSnapshot(` { "data": { - "node": null, + "people": [ + { + "dateOfBirth": "2001-01-01", + "name": "John Doe", + "nodeId": "UGVyc29uOnAx", + }, + { + "dateOfBirth": "2002-02-02", + "name": "Jane Doe", + "nodeId": "UGVyc29uOnAy", + }, + ], }, } `); - }); - - it('should resolve multiple fields key object from globally unique node', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - people, - }, - }); await expect( - Promise.resolve( - normalizedExecutor({ - schema, - document: parse(/* GraphQL */ ` - { - node(nodeId: "${toGlobalId('Person', JSON.stringify({ firstName: 'John', lastName: 'Doe' }))}") { - nodeId - ... on Person { - firstName - lastName + execute({ + query: /* GraphQL */ ` + { + feed { + nodeId # we omit the "title" and "publishedAt" key fields making sure it's resolved internally } } - } - `), - }), - ), + `, + }), ).resolves.toMatchInlineSnapshot(` { "data": { - "node": { - "firstName": "John", - "lastName": "Doe", - "nodeId": "UGVyc29uOnsiZmlyc3ROYW1lIjoiSm9obiIsImxhc3ROYW1lIjoiRG9lIn0=", - }, + "feed": [ + { + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IlBlcnNvbmFsIFN0b3J5IDEiLCJwdWJsaXNoZWRBdCI6IjIwMTEtMDEtMDEifQ==", + }, + { + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IlBlcnNvbmFsIFN0b3J5IDIiLCJwdWJsaXNoZWRBdCI6IjIwMTItMDItMDIifQ==", + }, + { + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IkNvcnBvcmF0ZSBTdG9yeSAzIiwicHVibGlzaGVkQXQiOiIyMDEzLTAzLTAzIn0=", + }, + ], }, } `); }); - it('should resolve node id without requesting key fields in client query', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - people, - }, - }); + it('should not resolve when object doesnt exist', async () => { + const { schema } = await getSchema(); await expect( Promise.resolve( normalizedExecutor({ schema, document: parse(/* GraphQL */ ` - { - people { - nodeId - } + { + node(nodeId: "${toGlobalId('Person', 'IDontExist')}") { + ... on Person { + nodeId + id + name + dateOfBirth } - `), + } + } + `), }), ), ).resolves.toMatchInlineSnapshot(` { "data": { - "people": [ - { - "nodeId": "UGVyc29uOnsiZmlyc3ROYW1lIjoiSm9obiIsImxhc3ROYW1lIjoiRG9lIn0=", - }, - { - "nodeId": "UGVyc29uOnsiZmlyc3ROYW1lIjoiSmFuZSIsImxhc3ROYW1lIjoiRG9lIn0=", - }, - ], + "node": null, }, } `); }); - it('should resolve node id from object', async () => { - const schema = await getStitchedSchemaFromLocalSchemas({ - globalObjectIdentification: true, - localSchemas: { - accounts, - }, - }); + it('should not resolve when invalid node id', async () => { + const { schema } = await getSchema(); await expect( Promise.resolve( @@ -377,11 +290,13 @@ describe('Global Object Identification', () => { schema, document: parse(/* GraphQL */ ` { - accounts { - nodeId - id - name - email + node(nodeId: "gibberish") { + ... on Organization { + nodeId + id + name + foundingDate + } } } `), @@ -390,16 +305,216 @@ describe('Global Object Identification', () => { ).resolves.toMatchInlineSnapshot(` { "data": { - "accounts": [ - { - "email": "john@doe.com", - "id": "a1", - "name": "John Doe", - "nodeId": "QWNjb3VudDphMQ==", - }, - ], + "node": null, }, } `); }); }); + +async function getSchema() { + const data = { + people: [ + { + id: 'p1', + name: 'John Doe', + dateOfBirth: '2001-01-01', + }, + { + id: 'p2', + name: 'Jane Doe', + dateOfBirth: '2002-02-02', + }, + ] as const, + organizations: [ + { + id: 'o3', + name: 'Foo Inc.', + foundingDate: '1993-03-03', + }, + { + id: 'o4', + name: 'Bar Inc.', + foundingDate: '1994-04-04', + }, + ] as const, + stories: [ + { + title: 'Personal Story 1', + publishedAt: '2011-01-01', + content: 'Lorem ipsum dolor sit amet.', + actor: { + id: 'p1', + }, + }, + { + title: 'Personal Story 2', + publishedAt: '2012-02-02', + content: 'Lorem ipsum dolor sit amet.', + actor: { + id: 'p2', + }, + }, + { + title: 'Corporate Story 3', + publishedAt: '2013-03-03', + content: 'Lorem ipsum dolor sit amet.', + actor: { + id: 'o3', + }, + }, + ] as const, + }; + + const users = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + people: [Person!]! + organizations: [Organization!]! + } + + type Person @key(fields: "id") { + id: ID! + dateOfBirth: String! + } + + type Organization @key(fields: "id") { + id: ID! + foundingDate: String! + } + `), + resolvers: { + Query: { + people: () => + data.people.map((p) => ({ + id: p.id, + dateOfBirth: p.dateOfBirth, + })), + organizations: () => + data.organizations.map((o) => ({ + id: o.id, + foundingDate: o.foundingDate, + })), + }, + Person: { + __resolveReference: (ref) => { + const person = data.people.find((p) => p.id === ref.id); + return person + ? { id: person.id, dateOfBirth: person.dateOfBirth } + : null; + }, + }, + Organization: { + __resolveReference: (ref) => { + const org = data.organizations.find((o) => o.id === ref.id); + return org ? { id: org.id, foundingDate: org.foundingDate } : null; + }, + }, + }, + }); + + const stories = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + feed: [Story!]! + } + + type Story @key(fields: "title publishedAt") { + title: String! + publishedAt: String! + actor: Actor! + content: String! + } + + interface Actor @key(fields: "id") { + id: ID! + name: String! + } + + type Person implements Actor @key(fields: "id") { + id: ID! + name: String! + } + + type Organization implements Actor @key(fields: "id") { + id: ID! + name: String! + } + `), + resolvers: { + Query: { + feed: () => data.stories, + }, + Actor: { + __resolveType: (ref) => { + if (data.people.find((p) => p.id === ref.id)) { + return 'Person'; + } + if (data.organizations.find((o) => o.id === ref.id)) { + return 'Organization'; + } + return null; + }, + }, + Person: { + __resolveReference(ref) { + const person = data.people.find((p) => p.id === ref.id); + return person ? { id: person.id, name: person.name } : null; + }, + name(source) { + const person = data.people.find((p) => p.id === source.id); + return person ? person.name : null; + }, + }, + Organization: { + __resolveReference: (ref) => { + const org = data.organizations.find((o) => o.id === ref.id); + return org ? { id: org.id, name: org.name } : null; + }, + name(source) { + const org = data.organizations.find((o) => o.id === source.id); + return org ? org.name : null; + }, + }, + Story: { + __resolveReference: (ref) => + data.stories.find( + (s) => s.title === ref.title && s.publishedAt === ref.publishedAt, + ), + }, + }, + }); + + const schema = await getStitchedSchemaFromLocalSchemas({ + globalObjectIdentification: true, + localSchemas: { + users, + stories, + }, + }); + + return { + data, + schema, + async execute({ + query, + variables, + }: { + query: string; + variables?: Record; + }) { + const document = parse(query); + const errs = validate(schema, document); + if (errs.length === 1) { + throw errs[0]; + } else if (errs.length) { + throw new AggregateError(errs, errs.map((e) => e.message).join('; ')); + } + return normalizedExecutor({ + schema, + document, + variableValues: variables, + }); + }, + }; +} From 3ecd0fab73d6c6bde4e56f44974507d0b15ccb06 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 12 Jun 2025 18:11:03 +0200 Subject: [PATCH 37/44] interfaces should implement node too --- .../tests/globalObjectIdentification.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/federation/tests/globalObjectIdentification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts index cd7d99999..6a3644b07 100644 --- a/packages/federation/tests/globalObjectIdentification.test.ts +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -26,9 +26,13 @@ describe('Global Object Identification', () => { ): Node } - interface Actor { + interface Actor implements Node { id: ID! name: String! + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! } type Organization implements Actor & Node { @@ -158,6 +162,34 @@ describe('Global Object Identification', () => { `); }); + it('should resolve single field key interface', async () => { + const { data, execute } = await getSchema(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Organization', data.organizations[0].id)}") { + ... on Actor { + nodeId + name + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "name": "Foo Inc.", + "nodeId": "T3JnYW5pemF0aW9uOm8z", + }, + }, + } + `); + }); + it('should resolve multiple fields key object', async () => { const { data, execute } = await getSchema(); From 96e0a18f2d0bf8db8f4d2b7137a25af7e40d856d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 13 Jun 2025 18:12:53 +0200 Subject: [PATCH 38/44] improvements --- .../src/globalObjectIdentification.ts | 149 ++++++++++-------- packages/federation/src/supergraph.ts | 37 +++-- 2 files changed, 113 insertions(+), 73 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index e04131398..221616759 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -1,17 +1,19 @@ import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; import { StitchingInfo, SubschemaConfig } from '@graphql-tools/delegate'; -import { IResolvers } from '@graphql-tools/utils'; +import { IResolvers, parseSelectionSet } from '@graphql-tools/utils'; import { DefinitionNode, FieldDefinitionNode, GraphQLList, GraphQLObjectType, InterfaceTypeDefinitionNode, + isObjectType, Kind, ObjectTypeExtensionNode, + SelectionSetNode, } from 'graphql'; import { fromGlobalId, toGlobalId } from 'graphql-relay'; -import { MergedTypeConfigFromEntities } from './supergraph'; +import { isMergedEntityConfig, MergedEntityConfig } from './supergraph'; export interface GlobalObjectIdentificationOptions { nodeIdField: string; @@ -64,7 +66,7 @@ export function createNodeDefinitions({ // extend type X implements Node - for (const { typeName } of getDistinctResolvableTypes(subschemas)) { + for (const { typeName } of getDistinctEntities(subschemas)) { const typeExtensionDef: ObjectTypeExtensionNode = { kind: Kind.OBJECT_TYPE_EXTENSION, name: { @@ -147,14 +149,14 @@ export function createResolvers({ nodeIdField, subschemas, }: GlobalObjectIdentificationOptions): IResolvers { - const types = getDistinctResolvableTypes(subschemas); + const types = getDistinctEntities(subschemas); return { ...types.reduce( - (resolvers, { typeName, keyFieldNames }) => ({ + (resolvers, { typeName, merge, keyFieldNames }) => ({ ...resolvers, [typeName]: { [nodeIdField]: { - selectionSet: `{ ${keyFieldNames.join(' ')} }`, + selectionSet: merge.selectionSet, resolve(source) { if (keyFieldNames.length === 1) { // single field key @@ -183,9 +185,7 @@ export function createResolvers({ } // we must use otherwise different schema - const types = getDistinctResolvableTypes( - stitchingInfo.subschemaMap.values(), - ); + const types = getDistinctEntities(stitchingInfo.subschemaMap.values()); const { id: idOrFields, type: typeName } = fromGlobalId(nodeId); const type = types.find((t) => t.typeName === typeName); @@ -211,85 +211,106 @@ export function createResolvers({ } return batchDelegateToSchema({ - ...type.merge, - info, context, + info, schema: type.subschema, + fieldName: type.merge.fieldName, + argsFromKeys: type.merge.argsFromKeys, + key: { ...keyFields, __typename: typeName }, // we already have all the necessary keys returnType: new GraphQLList( // wont ever be undefined, we ensured the subschema has the type above type.subschema.schema.getType(typeName) as GraphQLObjectType, ), - selectionSet: undefined, // selectionSet is not needed here - key: { ...keyFields, __typename: typeName }, // we already have all the necessary keys + dataLoaderOptions: type.merge.dataLoaderOptions, }); }, }, }; } -interface DistinctResolvableType { +interface DistinctEntity { typeName: string; subschema: SubschemaConfig; - merge: MergedTypeConfigFromEntities; + merge: MergedEntityConfig; keyFieldNames: string[]; } -function getDistinctResolvableTypes( - subschemas: Iterable, -): DistinctResolvableType[] { - const visitedTypeNames = new Set(); - const types: DistinctResolvableType[] = []; - for (const subschema of subschemas) { - // TODO: respect canonical types - for (const [typeName, merge] of Object.entries(subschema.merge || {}) - .filter( - // make sure selectionset is defined for the sort to work - ([, merge]) => merge.selectionSet, +function getDistinctEntities( + subschemasIter: Iterable, +): DistinctEntity[] { + const distinctEntities: DistinctEntity[] = []; + + const subschemas = Array.from(subschemasIter); + const types = subschemas.flatMap((subschema) => + Object.values(subschema.schema.getTypeMap()), + ); + + const objects = types.filter(isObjectType); + for (const obj of objects) { + if ( + distinctEntities.find( + (distinctType) => distinctType.typeName === obj.name, ) - .sort( - // sort by shortest keys first - ([, a], [, b]) => a.selectionSet!.length - b.selectionSet!.length, - )) { - if (visitedTypeNames.has(typeName)) { - // already yielded this type, all types can only have one resolution + ) { + // already added this type + continue; + } + let candidate: { + subschema: SubschemaConfig; + merge: MergedEntityConfig; + } | null = null; + for (const subschema of subschemas) { + const merge = subschema.merge?.[obj.name]; + if (!merge) { + // not resolvable from this subschema continue; } - - if ( - !merge.selectionSet || - !merge.argsFromKeys || - !merge.key || - !merge.fieldName || - !merge.dataLoaderOptions - ) { - // cannot be resolved globally + if (!isMergedEntityConfig(merge)) { + // not a merged entity config, cannot be resolved globally continue; } - - // remove first and last characters from the selection set making up the key (curly braces, `{ id } -> id`) - const key = merge.selectionSet.trim().slice(1, -1).trim(); - if ( - // the key for fetching this object contains other objects - key.includes('{') || - // the key for fetching this object contains arguments - key.includes('(') || - // the key contains aliases - key.includes(':') - ) { - // it's too complex to use global object identification - // TODO: do it anyways when need arises + if (merge.canonical) { + // this subschema is canonical (owner) for this type, no need to check other schemas + candidate = { subschema, merge }; + break; + } + if (!candidate) { + // first merge candidate + candidate = { subschema, merge }; continue; } - // what we're left in the "key" are simple field(s) like "id" or "email" - - visitedTypeNames.add(typeName); - types.push({ - typeName, - subschema, - merge: merge as MergedTypeConfigFromEntities, - keyFieldNames: key.trim().split(/\s+/), - }); + if (merge.selectionSet.length < candidate.merge.selectionSet.length) { + // found a better candidate + candidate = { subschema, merge }; + } + } + if (!candidate) { + // no merge candidate found, cannot be resolved globally + continue; } + // is an entity that can efficiently be resolved globally + distinctEntities.push({ + ...candidate, + typeName: obj.name, + keyFieldNames: (function getRootFieldNames( + selectionSet: SelectionSetNode, + ): string[] { + const fieldNames: string[] = []; + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.FRAGMENT_SPREAD) { + throw new Error('Fragment spreads cannot appear in @key fields'); + } + if (sel.kind === Kind.INLINE_FRAGMENT) { + fieldNames.push(...getRootFieldNames(sel.selectionSet)); + continue; + } + // Kind.FIELD + fieldNames.push(sel.alias?.value || sel.name.value); + } + return fieldNames; + })(parseSelectionSet(candidate.merge.selectionSet)), + }); } - return types; + + return distinctEntities; } diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index faa16cd97..a8347c43a 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -871,9 +871,7 @@ export function getStitchingOptionsFromSupergraphSdl( mergedTypeConfig.canonical = true; } - function getMergedTypeConfigFromKey( - key: string, - ): MergedTypeConfigFromEntities { + function getMergedTypeConfigFromKey(key: string): MergedEntityConfig { return { selectionSet: `{ ${key} }`, argsFromKeys: getArgsFromKeysForFederation, @@ -1748,9 +1746,30 @@ function mergeResults(results: unknown[], getFieldNames: () => Set) { return null; } -export type MergedTypeConfigFromEntities = Required< - Pick< - MergedTypeConfig, - 'selectionSet' | 'argsFromKeys' | 'key' | 'fieldName' | 'dataLoaderOptions' - > ->; +/** + * A merge type configuration for resolving types that are Apollo Federation entities. + * @see https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/entities/intro + */ +export type MergedEntityConfig = MergedTypeConfig & + Required< + Pick< + MergedTypeConfig, + | 'selectionSet' + | 'argsFromKeys' + | 'key' + | 'fieldName' + | 'dataLoaderOptions' + > + >; + +export function isMergedEntityConfig( + merge: MergedTypeConfig, +): merge is MergedEntityConfig { + return ( + 'selectionSet' in merge && + 'argsFromKeys' in merge && + 'key' in merge && + 'fieldName' in merge && + 'dataLoaderOptions' in merge + ); +} From 68b99cb7ad78bf7b63d22349fa79c1a749751114 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 23 Jun 2025 18:24:17 +0200 Subject: [PATCH 39/44] interface support --- .../src/globalObjectIdentification.ts | 103 ++++++++++++++---- .../tests/globalObjectIdentification.test.ts | 35 +++++- 2 files changed, 113 insertions(+), 25 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 221616759..fb478549f 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -7,6 +7,8 @@ import { GraphQLList, GraphQLObjectType, InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + isInterfaceType, isObjectType, Kind, ObjectTypeExtensionNode, @@ -66,9 +68,14 @@ export function createNodeDefinitions({ // extend type X implements Node - for (const { typeName } of getDistinctEntities(subschemas)) { - const typeExtensionDef: ObjectTypeExtensionNode = { - kind: Kind.OBJECT_TYPE_EXTENSION, + for (const { typeName, kind } of getDistinctEntities(subschemas)) { + const typeExtensionDef: + | ObjectTypeExtensionNode + | InterfaceTypeExtensionNode = { + kind: + kind === 'object' + ? Kind.OBJECT_TYPE_EXTENSION + : Kind.INTERFACE_TYPE_EXTENSION, name: { kind: Kind.NAME, value: typeName, @@ -149,7 +156,13 @@ export function createResolvers({ nodeIdField, subschemas, }: GlobalObjectIdentificationOptions): IResolvers { - const types = getDistinctEntities(subschemas); + // we can safely skip interfaces here because the concrete type will be known + // when resolving and the type will always be an object + // + // the nodeIdField will ALWAYS be the global ID identifying the concrete object + const types = getDistinctEntities(subschemas).filter( + (t) => t.kind === 'object', + ); return { ...types.reduce( (resolvers, { typeName, merge, keyFieldNames }) => ({ @@ -184,24 +197,28 @@ export function createResolvers({ return null; // no stitching info, something went wrong // TODO: throw instead? } - // we must use otherwise different schema - const types = getDistinctEntities(stitchingInfo.subschemaMap.values()); + // TODO: potential performance bottleneck, memoize + const entities = getDistinctEntities( + // the stitchingInfo.subschemaMap.values() is different from subschemas. it + // contains the actual source of truth with all resolvers prepared - use it + stitchingInfo.subschemaMap.values(), + ).filter((t) => t.kind === 'object'); const { id: idOrFields, type: typeName } = fromGlobalId(nodeId); - const type = types.find((t) => t.typeName === typeName); - if (!type) { - return null; // unknown type + const entity = entities.find((t) => t.typeName === typeName); + if (!entity) { + return null; // unknown object type } const keyFields: Record = {}; - if (type.keyFieldNames.length === 1) { + if (entity.keyFieldNames.length === 1) { // single field key - keyFields[type.keyFieldNames[0]!] = idOrFields; + keyFields[entity.keyFieldNames[0]!] = idOrFields; } else { // multiple fields key try { const idFields = JSON.parse(idOrFields); - for (const fieldName of type.keyFieldNames) { + for (const fieldName of entity.keyFieldNames) { // loop is faster than reduce keyFields[fieldName] = idFields[fieldName]; } @@ -213,32 +230,45 @@ export function createResolvers({ return batchDelegateToSchema({ context, info, - schema: type.subschema, - fieldName: type.merge.fieldName, - argsFromKeys: type.merge.argsFromKeys, + schema: entity.subschema, + fieldName: entity.merge.fieldName, + argsFromKeys: entity.merge.argsFromKeys, key: { ...keyFields, __typename: typeName }, // we already have all the necessary keys returnType: new GraphQLList( // wont ever be undefined, we ensured the subschema has the type above - type.subschema.schema.getType(typeName) as GraphQLObjectType, + entity.subschema.schema.getType(typeName) as GraphQLObjectType, ), - dataLoaderOptions: type.merge.dataLoaderOptions, + dataLoaderOptions: entity.merge.dataLoaderOptions, }); }, }, }; } -interface DistinctEntity { +interface DistinctEntityInterface { + kind: 'interface'; + typeName: string; +} + +interface DistinctEntityObject { + kind: 'object'; typeName: string; subschema: SubschemaConfig; merge: MergedEntityConfig; keyFieldNames: string[]; } +type DistinctEntity = DistinctEntityObject | DistinctEntityInterface; + function getDistinctEntities( subschemasIter: Iterable, ): DistinctEntity[] { const distinctEntities: DistinctEntity[] = []; + function entityExists(typeName: string): boolean { + return distinctEntities.some( + (distinctType) => distinctType.typeName === typeName, + ); + } const subschemas = Array.from(subschemasIter); const types = subschemas.flatMap((subschema) => @@ -247,11 +277,7 @@ function getDistinctEntities( const objects = types.filter(isObjectType); for (const obj of objects) { - if ( - distinctEntities.find( - (distinctType) => distinctType.typeName === obj.name, - ) - ) { + if (entityExists(obj.name)) { // already added this type continue; } @@ -291,6 +317,7 @@ function getDistinctEntities( // is an entity that can efficiently be resolved globally distinctEntities.push({ ...candidate, + kind: 'object', typeName: obj.name, keyFieldNames: (function getRootFieldNames( selectionSet: SelectionSetNode, @@ -312,5 +339,35 @@ function getDistinctEntities( }); } + // object entities must exist in order to support interfaces + if (distinctEntities.length) { + const interfaces = types.filter(isInterfaceType); + Interfaces: for (const inter of interfaces) { + if (entityExists(inter.name)) { + // already added this interface + continue; + } + // check if this interface is implemented exclusively by the entity objects + for (const subschema of subschemas) { + const impls = subschema.schema.getImplementations(inter); + if (impls.interfaces.length) { + // this interface is implemented by other interfaces, we wont be handling those atm + // TODO: handle interfaces that implement other interfaces + continue Interfaces; + } + if (!impls.objects.every(({ name }) => entityExists(name))) { + // implementing objects of this interface are not all distinct entities + // i.e. some implementing objects don't have the node id field + continue Interfaces; + } + } + // all subschemas entities implement exclusively this interface + distinctEntities.push({ + kind: 'interface', + typeName: inter.name, + }); + } + } + return distinctEntities; } diff --git a/packages/federation/tests/globalObjectIdentification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts index 6a3644b07..cfaf93a6f 100644 --- a/packages/federation/tests/globalObjectIdentification.test.ts +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -162,7 +162,7 @@ describe('Global Object Identification', () => { `); }); - it('should resolve single field key interface', async () => { + it('should resolve single field key interface with implementing object node id', async () => { const { data, execute } = await getSchema(); await expect( @@ -171,7 +171,7 @@ describe('Global Object Identification', () => { { node(nodeId: "${toGlobalId('Organization', data.organizations[0].id)}") { ... on Actor { - nodeId + nodeId # even though on Actor, the nodeId will be the global ID of the implementing type name } } @@ -190,6 +190,37 @@ describe('Global Object Identification', () => { `); }); + it('should not resolve single field key interface with interface node id', async () => { + const { data, execute } = await getSchema(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Actor', data.organizations[0].id)}") { + ... on Actor { + nodeId + name + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": null, + }, + } + `); + + // the node here will be always null because Actor is an interface and we cannot resolve it by id + // we can only resolve it by the implementing type id, which is Organization or Person in this case. + // + // we can also safely assume that the nodeId in an interface will never be generated a global ID for + // the interface itself - it will always be the global ID of the implementing type that got resolved + }); + it('should resolve multiple fields key object', async () => { const { data, execute } = await getSchema(); From ac30464e56a069f358ad110d207883345dc037ed Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 23 Jun 2025 18:57:29 +0200 Subject: [PATCH 40/44] configurable --- .../src/globalObjectIdentification.ts | 25 +++++++----- packages/federation/src/index.ts | 1 + packages/federation/src/supergraph.ts | 39 +++++++++++-------- .../getStitchedSchemaFromLocalSchemas.ts | 3 +- .../fusion-runtime/src/unifiedGraphManager.ts | 9 +++-- packages/runtime/src/types.ts | 8 ++-- 6 files changed, 50 insertions(+), 35 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index fb478549f..f05419abc 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -18,14 +18,21 @@ import { fromGlobalId, toGlobalId } from 'graphql-relay'; import { isMergedEntityConfig, MergedEntityConfig } from './supergraph'; export interface GlobalObjectIdentificationOptions { + /** + * The field name of the global ID on the Node interface. + * + * The `Node` interface defaults to `nodeId`, not `id`! It is intentionally not + * `id` to avoid collisions with existing `id` fields in subgraphs. + * + * @default nodeId + */ nodeIdField: string; - subschemas: SubschemaConfig[]; } -export function createNodeDefinitions({ - nodeIdField, - subschemas, -}: GlobalObjectIdentificationOptions) { +export function createNodeDefinitions( + subschemas: SubschemaConfig[], + { nodeIdField }: GlobalObjectIdentificationOptions, +) { const defs: DefinitionNode[] = []; // nodeId: ID @@ -152,10 +159,10 @@ export function createNodeDefinitions({ return defs; } -export function createResolvers({ - nodeIdField, - subschemas, -}: GlobalObjectIdentificationOptions): IResolvers { +export function createResolvers( + subschemas: SubschemaConfig[], + { nodeIdField }: GlobalObjectIdentificationOptions, +): IResolvers { // we can safely skip interfaces here because the concrete type will be known // when resolving and the type will always be an object // diff --git a/packages/federation/src/index.ts b/packages/federation/src/index.ts index 3192f9209..e8aae385e 100644 --- a/packages/federation/src/index.ts +++ b/packages/federation/src/index.ts @@ -1,3 +1,4 @@ export * from './managed-federation.js'; export * from './supergraph.js'; +export * from './globalObjectIdentification.js'; export * from './utils.js'; diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index a8347c43a..3605a7e73 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -70,6 +70,7 @@ import { import { createNodeDefinitions, createResolvers, + GlobalObjectIdentificationOptions, } from './globalObjectIdentification.js'; import { filterInternalFieldsAndTypes, @@ -135,10 +136,7 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { batchDelegateOptions?: MergedTypeConfig['dataLoaderOptions']; /** * Add support for GraphQL Global Object Identification Specification by adding a `Node` - * interface, `node(id: ID!): Node` and `nodes(ids: [ID!]!): [Node!]!` fields to the `Query` type. - * - * The `Node` interface will have a `nodeId` (not `id`!) field used as the global identifier. It - * is intentionally not `id` to avoid collisions with existing `id` fields in subgraphs. + * interface, `node(nodeId: ID!): Node` and `nodes(nodeIds: [ID!]!): [Node!]!` fields to the `Query` type. * * ```graphql * """An object with a globally unique `ID`.""" @@ -165,7 +163,7 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { * * @see https://graphql.org/learn/global-object-identification/ */ - globalObjectIdentification?: boolean; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; } export function getStitchingOptionsFromSupergraphSdl( @@ -1548,30 +1546,37 @@ export function getStitchingOptionsFromSupergraphSdl( opts.onSubschemaConfig(subschema as FederationSubschemaConfig); } } - const shouldGlobalObjectIdent = - opts.globalObjectIdentification && typeNameKeysBySubgraphMap.size; + const globalObjectIdentification: GlobalObjectIdentificationOptions | null = + opts.globalObjectIdentification === true + ? // defaults + { + nodeIdField: 'nodeId', + } + : typeof opts.globalObjectIdentification === 'object' + ? // user configuration + opts.globalObjectIdentification + : null; + if (globalObjectIdentification && !typeNameKeysBySubgraphMap.size) { + throw new Error( + 'Automatic Global Object Identification is enabled, but no subgraphs have entities defined with defined keys. Please ensure that at least one subgraph has a type with the `@key` directive making it an entity.', + ); + } return { subschemas, typeDefs: { kind: Kind.DOCUMENT, - definitions: !shouldGlobalObjectIdent + definitions: !globalObjectIdentification ? extraDefinitions : [ ...extraDefinitions, - ...createNodeDefinitions({ - nodeIdField: 'nodeId', - subschemas, - }), + ...createNodeDefinitions(subschemas, globalObjectIdentification), ], } as DocumentNode, assumeValid: true, assumeValidSDL: true, - resolvers: !shouldGlobalObjectIdent + resolvers: !globalObjectIdentification ? undefined - : createResolvers({ - nodeIdField: 'nodeId', - subschemas, - }), + : createResolvers(subschemas, globalObjectIdentification), typeMergingOptions: { useNonNullableFieldOnConflict: true, validationSettings: { diff --git a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts index 8f39045d4..dad4e2f30 100644 --- a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts +++ b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts @@ -9,6 +9,7 @@ import { composeServices } from '@theguild/federation-composition'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; import { GraphQLSchema } from 'graphql'; import { kebabCase } from 'lodash'; +import { GlobalObjectIdentificationOptions } from '../src/globalObjectIdentification'; import { getStitchedSchemaFromSupergraphSdl } from '../src/supergraph'; export interface LocalSchemaItem { @@ -31,7 +32,7 @@ export async function getStitchedSchemaFromLocalSchemas({ ) => void; composeWith?: 'apollo' | 'guild'; ignoreRules?: string[]; - globalObjectIdentification?: boolean; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; }): Promise { let supergraphSdl: string; if (composeWith === 'apollo') { diff --git a/packages/fusion-runtime/src/unifiedGraphManager.ts b/packages/fusion-runtime/src/unifiedGraphManager.ts index 9e31c3f60..7a8e53de2 100644 --- a/packages/fusion-runtime/src/unifiedGraphManager.ts +++ b/packages/fusion-runtime/src/unifiedGraphManager.ts @@ -5,6 +5,7 @@ import type { import type { Logger, OnDelegateHook } from '@graphql-mesh/types'; import { dispose, isDisposable } from '@graphql-mesh/utils'; import { CRITICAL_ERROR } from '@graphql-tools/executor'; +import type { GlobalObjectIdentificationOptions } from '@graphql-tools/federation'; import type { ExecutionRequest, Executor, @@ -68,7 +69,7 @@ export interface UnifiedGraphHandlerOpts { onDelegationPlanHooks?: OnDelegationPlanHook[]; onDelegationStageExecuteHooks?: OnDelegationStageExecuteHook[]; onDelegateHooks?: OnDelegateHook[]; - globalObjectIdentification?: boolean; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; logger?: Logger; } @@ -109,7 +110,7 @@ export interface UnifiedGraphManagerOptions { onUnifiedGraphChange?(newUnifiedGraph: GraphQLSchema): void; - globalObjectIdentification?: boolean; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; } export type Instrumentation = { @@ -140,7 +141,9 @@ export class UnifiedGraphManager implements AsyncDisposable { private lastLoadTime?: number; private executor?: Executor; private instrumentation: () => Instrumentation | undefined; - private globalObjectIdentification: boolean; + private globalObjectIdentification: + | boolean + | GlobalObjectIdentificationOptions; constructor(private opts: UnifiedGraphManagerOptions) { this.batch = opts.batch ?? true; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 3f9d40fc7..0e4ac70d8 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -17,6 +17,7 @@ import type { } from '@graphql-mesh/types'; import type { FetchInstrumentation, LogLevel } from '@graphql-mesh/utils'; import type { HTTPExecutorOptions } from '@graphql-tools/executor-http'; +import type { GlobalObjectIdentificationOptions } from '@graphql-tools/federation'; import type { IResolvers, MaybePromise, @@ -195,10 +196,7 @@ export interface GatewayConfigSupergraph< pollingInterval?: number; /** * Add support for GraphQL Global Object Identification Specification by adding a `Node` - * interface, `node(id: ID!): Node` and `nodes(ids: [ID!]!): [Node!]!` fields to the `Query` type. - * - * The `Node` interface will have a `nodeId` (not `id`!) field used as the global identifier. It - * is intentionally not `id` to avoid collisions with existing `id` fields in subgraphs. + * interface, `node(nodeId: ID!): Node` and `nodes(nodeIds: [ID!]!): [Node!]!` fields to the `Query` type. * * ```graphql * """An object with a globally unique `ID`.""" @@ -225,7 +223,7 @@ export interface GatewayConfigSupergraph< * * @see https://graphql.org/learn/global-object-identification/ */ - globalObjectIdentification?: boolean; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; } export interface GatewayConfigSubgraph< From 156195254c55d95e225893056bb1fd08be2e6a62 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 23 Jun 2025 19:14:29 +0200 Subject: [PATCH 41/44] nodeidfield in args --- packages/federation/src/globalObjectIdentification.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index f05419abc..8dc675cf4 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -196,7 +196,7 @@ export function createResolvers( {} as Record, ), Query: { - node(_source, { nodeId }, context, info) { + node(_source, args, context, info) { const stitchingInfo = info.schema.extensions?.['stitchingInfo'] as | StitchingInfo | undefined; @@ -211,7 +211,9 @@ export function createResolvers( stitchingInfo.subschemaMap.values(), ).filter((t) => t.kind === 'object'); - const { id: idOrFields, type: typeName } = fromGlobalId(nodeId); + const { id: idOrFields, type: typeName } = fromGlobalId( + args[nodeIdField], + ); const entity = entities.find((t) => t.typeName === typeName); if (!entity) { return null; // unknown object type From 1cf5266edc5bb71dcfd6c1cfbdd6e13c2af0d75f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 23 Jun 2025 19:17:56 +0200 Subject: [PATCH 42/44] no Query.nodes for now --- .changeset/rare-pants-develop.md | 12 +----------- packages/federation/src/supergraph.ts | 9 ++------- packages/runtime/src/types.ts | 9 ++------- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/.changeset/rare-pants-develop.md b/.changeset/rare-pants-develop.md index 24ffba25b..03a707a81 100644 --- a/.changeset/rare-pants-develop.md +++ b/.changeset/rare-pants-develop.md @@ -7,8 +7,7 @@ Automatic Global Object Identification Setting the `globalObjectIdentification` option to true will automatically implement the -GraphQL Global Object Identification Specification by adding a `Node` interface, `node(id: ID!): Node` -and `nodes(ids: [ID!]!): [Node!]!` fields to the `Query` type. +GraphQL Global Object Identification Specification by adding a `Node` interface and `node(id: ID!): Node` field to the `Query` type. The `Node` interface will have a `nodeId` (not `id`!) field used as the global identifier. It is intentionally not `id` to avoid collisions with existing `id` fields in subgraphs. @@ -34,14 +33,5 @@ extend type Query { """ nodeId: ID! ): Node - """ - Fetches objects given their globally unique `ID`s. - """ - nodes( - """ - The globally unique `ID`s. - """ - nodeIds: [ID!]! - ): [Node!]! } ``` diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 3605a7e73..dd503e189 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -135,8 +135,8 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { */ batchDelegateOptions?: MergedTypeConfig['dataLoaderOptions']; /** - * Add support for GraphQL Global Object Identification Specification by adding a `Node` - * interface, `node(nodeId: ID!): Node` and `nodes(nodeIds: [ID!]!): [Node!]!` fields to the `Query` type. + * Add support for GraphQL Global Object Identification Specification by adding a `Node` + * interface and `node(nodeId: ID!): Node` field to the `Query` type. * * ```graphql * """An object with a globally unique `ID`.""" @@ -153,11 +153,6 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { * """The globally unique `ID`.""" * nodeId: ID! * ): Node - * """Fetches objects given their globally unique `ID`s.""" - * nodes( - * """The globally unique `ID`s.""" - * nodeIds: [ID!]! - * ): [Node!]! * } * ``` * diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 0e4ac70d8..73493c219 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -195,8 +195,8 @@ export interface GatewayConfigSupergraph< */ pollingInterval?: number; /** - * Add support for GraphQL Global Object Identification Specification by adding a `Node` - * interface, `node(nodeId: ID!): Node` and `nodes(nodeIds: [ID!]!): [Node!]!` fields to the `Query` type. + * Add support for GraphQL Global Object Identification Specification by adding a `Node` + * interface and `node(nodeId: ID!): Node` field to the `Query` type. * * ```graphql * """An object with a globally unique `ID`.""" @@ -213,11 +213,6 @@ export interface GatewayConfigSupergraph< * """The globally unique `ID`.""" * nodeId: ID! * ): Node - * """Fetches objects given their globally unique `ID`s.""" - * nodes( - * """The globally unique `ID`s.""" - * nodeIds: [ID!]! - * ): [Node!]! * } * ``` * From 2d32ff63f92d606ce9f49729627fa5bafed84067 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 23 Jun 2025 19:27:57 +0200 Subject: [PATCH 43/44] configurable from/to id --- .../src/globalObjectIdentification.ts | 36 ++++++++++++++++--- packages/federation/src/supergraph.ts | 4 +-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 8dc675cf4..3aee79098 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -14,9 +14,16 @@ import { ObjectTypeExtensionNode, SelectionSetNode, } from 'graphql'; -import { fromGlobalId, toGlobalId } from 'graphql-relay'; +import * as graphqlRelay from 'graphql-relay'; import { isMergedEntityConfig, MergedEntityConfig } from './supergraph'; +export interface ResolvedGlobalId { + /** The concrete type of the globally identifiable node. */ + type: string; + /** The actual ID of the concrete type in the relevant source. */ + id: string; +} + export interface GlobalObjectIdentificationOptions { /** * The field name of the global ID on the Node interface. @@ -26,12 +33,29 @@ export interface GlobalObjectIdentificationOptions { * * @default nodeId */ - nodeIdField: string; + nodeIdField?: string; + /** + * Takes a type name and an ID specific to that type name, and returns a + * "global ID" that is unique among all types. + * + * Note that the global ID can contain a JSON stringified object which + * contains multiple key fields needed to identify the object. + * + * @default import('graphql-relay').toGlobalId + */ + toGlobalId?(type: string, id: string | number): string; + /** + * Takes the "global ID" created by toGlobalID, and returns the type name and ID + * used to create it. + * + * @default import('graphql-relay').fromGlobalId + */ + fromGlobalId?(globalId: string): ResolvedGlobalId; } export function createNodeDefinitions( subschemas: SubschemaConfig[], - { nodeIdField }: GlobalObjectIdentificationOptions, + { nodeIdField = 'nodeId' }: GlobalObjectIdentificationOptions, ) { const defs: DefinitionNode[] = []; @@ -161,7 +185,11 @@ export function createNodeDefinitions( export function createResolvers( subschemas: SubschemaConfig[], - { nodeIdField }: GlobalObjectIdentificationOptions, + { + nodeIdField = 'nodeId', + fromGlobalId = graphqlRelay.fromGlobalId, + toGlobalId = graphqlRelay.toGlobalId, + }: GlobalObjectIdentificationOptions, ): IResolvers { // we can safely skip interfaces here because the concrete type will be known // when resolving and the type will always be an object diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index dd503e189..812937819 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -1544,9 +1544,7 @@ export function getStitchingOptionsFromSupergraphSdl( const globalObjectIdentification: GlobalObjectIdentificationOptions | null = opts.globalObjectIdentification === true ? // defaults - { - nodeIdField: 'nodeId', - } + {} : typeof opts.globalObjectIdentification === 'object' ? // user configuration opts.globalObjectIdentification From 528b9ad3ecc81ee9fc6444ab0046799f44e1cd10 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 23 Jun 2025 19:35:11 +0200 Subject: [PATCH 44/44] no node collision --- packages/federation/src/globalObjectIdentification.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/federation/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts index 3aee79098..04ff4d96f 100644 --- a/packages/federation/src/globalObjectIdentification.ts +++ b/packages/federation/src/globalObjectIdentification.ts @@ -312,6 +312,17 @@ function getDistinctEntities( Object.values(subschema.schema.getTypeMap()), ); + for (const type of types) { + if (type.name === 'Node') { + throw new Error( + `The "Node" interface is reserved for Automatic Global Object Identification and should not be defined in subgraphs. Interface is found in the following subgraphs: ${subschemas + .filter((s) => s.schema.getType('Node')) + .map((s) => `"${s.name!}"`) + .join(', ')}`, + ); + } + } + const objects = types.filter(isObjectType); for (const obj of objects) { if (entityExists(obj.name)) {