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`) diff --git a/.changeset/rare-pants-develop.md b/.changeset/rare-pants-develop.md new file mode 100644 index 000000000..03a707a81 --- /dev/null +++ b/.changeset/rare-pants-develop.md @@ -0,0 +1,37 @@ +--- +'@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 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. + +```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 +} +``` 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..15748fd9c --- /dev/null +++ b/e2e/global-object-identification/package.json @@ -0,0 +1,8 @@ +{ + "name": "@e2e/global-object-identification", + "private": true, + "dependencies": { + "graphql": "^16.11.0", + "graphql-relay": "^0.10.2" + } +} 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/package.json b/packages/federation/package.json index 617f445b1..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:^", @@ -51,6 +52,7 @@ "@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/src/globalObjectIdentification.ts b/packages/federation/src/globalObjectIdentification.ts new file mode 100644 index 000000000..04ff4d96f --- /dev/null +++ b/packages/federation/src/globalObjectIdentification.ts @@ -0,0 +1,421 @@ +import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; +import { StitchingInfo, SubschemaConfig } from '@graphql-tools/delegate'; +import { IResolvers, parseSelectionSet } from '@graphql-tools/utils'; +import { + DefinitionNode, + FieldDefinitionNode, + GraphQLList, + GraphQLObjectType, + InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + isInterfaceType, + isObjectType, + Kind, + ObjectTypeExtensionNode, + SelectionSetNode, +} from 'graphql'; +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. + * + * 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; + /** + * 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 = 'nodeId' }: 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, 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, + }, + 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( + subschemas: SubschemaConfig[], + { + 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 + // + // 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 }) => ({ + ...resolvers, + [typeName]: { + [nodeIdField]: { + selectionSet: merge.selectionSet, + 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)); + }, + }, + }, + }), + {} as Record, + ), + Query: { + node(_source, args, context, info) { + const stitchingInfo = info.schema.extensions?.['stitchingInfo'] as + | StitchingInfo + | undefined; + if (!stitchingInfo) { + return null; // no stitching info, something went wrong // TODO: throw instead? + } + + // 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( + args[nodeIdField], + ); + const entity = entities.find((t) => t.typeName === typeName); + if (!entity) { + return null; // unknown object type + } + + const keyFields: Record = {}; + if (entity.keyFieldNames.length === 1) { + // single field key + keyFields[entity.keyFieldNames[0]!] = idOrFields; + } else { + // multiple fields key + try { + const idFields = JSON.parse(idOrFields); + for (const fieldName of entity.keyFieldNames) { + // loop is faster than reduce + keyFields[fieldName] = idFields[fieldName]; + } + } catch { + return null; // invalid JSON i.e. invalid global ID + } + } + + return batchDelegateToSchema({ + context, + info, + 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 + entity.subschema.schema.getType(typeName) as GraphQLObjectType, + ), + dataLoaderOptions: entity.merge.dataLoaderOptions, + }); + }, + }, + }; +} + +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) => + 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)) { + // 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 (!isMergedEntityConfig(merge)) { + // not a merged entity config, cannot be resolved globally + continue; + } + 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; + } + 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, + kind: 'object', + 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)), + }); + } + + // 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/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/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/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 55ef24126..812937819 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -67,6 +67,11 @@ import { visit, visitWithTypeInfo, } from 'graphql'; +import { + createNodeDefinitions, + createResolvers, + GlobalObjectIdentificationOptions, +} from './globalObjectIdentification.js'; import { filterInternalFieldsAndTypes, getArgsFromKeysForFederation, @@ -129,6 +134,31 @@ export interface GetStitchingOptionsFromSupergraphSdlOpts { * Configure the batch delegation options for all merged types in all subschemas. */ batchDelegateOptions?: MergedTypeConfig['dataLoaderOptions']; + /** + * 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`.""" + * 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 + * } + * ``` + * + * @see https://graphql.org/learn/global-object-identification/ + */ + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; } export function getStitchingOptionsFromSupergraphSdl( @@ -834,7 +864,7 @@ export function getStitchingOptionsFromSupergraphSdl( mergedTypeConfig.canonical = true; } - function getMergedTypeConfigFromKey(key: string) { + function getMergedTypeConfigFromKey(key: string): MergedEntityConfig { return { selectionSet: `{ ${key} }`, argsFromKeys: getArgsFromKeysForFederation, @@ -1506,20 +1536,40 @@ 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 globalObjectIdentification: GlobalObjectIdentificationOptions | null = + opts.globalObjectIdentification === true + ? // defaults + {} + : 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: additionalTypeDefs, + typeDefs: { + kind: Kind.DOCUMENT, + definitions: !globalObjectIdentification + ? extraDefinitions + : [ + ...extraDefinitions, + ...createNodeDefinitions(subschemas, globalObjectIdentification), + ], + } as DocumentNode, assumeValid: true, assumeValidSDL: true, + resolvers: !globalObjectIdentification + ? undefined + : createResolvers(subschemas, globalObjectIdentification), typeMergingOptions: { useNonNullableFieldOnConflict: true, validationSettings: { @@ -1693,3 +1743,31 @@ function mergeResults(results: unknown[], getFieldNames: () => Set) { } return null; } + +/** + * 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 + ); +} diff --git a/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts b/packages/federation/tests/getStitchedSchemaFromLocalSchemas.ts index ac65bc445..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 { @@ -21,6 +22,7 @@ export async function getStitchedSchemaFromLocalSchemas({ onSubgraphExecute, composeWith = 'apollo', ignoreRules, + globalObjectIdentification, }: { localSchemas: Record; onSubgraphExecute?: ( @@ -30,6 +32,7 @@ export async function getStitchedSchemaFromLocalSchemas({ ) => void; composeWith?: 'apollo' | 'guild'; ignoreRules?: string[]; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; }): Promise { let supergraphSdl: string; if (composeWith === 'apollo') { @@ -73,6 +76,7 @@ export async function getStitchedSchemaFromLocalSchemas({ } return getStitchedSchemaFromSupergraphSdl({ supergraphSdl, + globalObjectIdentification, onSubschemaConfig(subschemaConfig) { const [name, localSchema] = Object.entries(localSchemas).find( @@ -80,7 +84,7 @@ export async function getStitchedSchemaFromLocalSchemas({ ) || []; if (name && localSchema) { subschemaConfig.executor = createTracedExecutor(name, localSchema); - } else { + } else if (!globalObjectIdentification) { throw new Error(`Unknown subgraph ${subschemaConfig.name}`); } }, diff --git a/packages/federation/tests/globalObjectIdentification.test.ts b/packages/federation/tests/globalObjectIdentification.test.ts new file mode 100644 index 000000000..cfaf93a6f --- /dev/null +++ b/packages/federation/tests/globalObjectIdentification.test.ts @@ -0,0 +1,583 @@ +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { normalizedExecutor } from '@graphql-tools/executor'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { parse, validate } from 'graphql'; +import { toGlobalId } from 'graphql-relay'; +import { describe, expect, it } from 'vitest'; +import { getStitchedSchemaFromLocalSchemas } from './getStitchedSchemaFromLocalSchemas'; + +describe('Global Object Identification', () => { + it('should generate stitched schema with node interface', async () => { + const { schema } = await getSchema(); + + expect(printSchemaWithDirectives(schema)).toMatchInlineSnapshot(` + "schema { + query: Query + } + + type Query { + feed: [Story!]! + people: [Person!]! + organizations: [Organization!]! + """Fetches an object given its globally unique \`ID\`.""" + node( + """The globally unique \`ID\`.""" + nodeId: ID! + ): Node + } + + 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 { + 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. + """ + nodeId: ID! + } + + interface Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + }" + `); + }); + + it('should resolve without node as usual', async () => { + const { execute } = await getSchema(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + feed { + title + content + actor { + name + ... on Person { + dateOfBirth + } + ... on Organization { + foundingDate + } + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "feed": [ + { + "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", + }, + ], + }, + } + `); + }); + + it('should resolve single field key object', async () => { + const { data, execute } = await getSchema(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Person', data.people[0].id)}") { + nodeId + ... on Person { + name + dateOfBirth + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "dateOfBirth": "2001-01-01", + "name": "John Doe", + "nodeId": "UGVyc29uOnAx", + }, + }, + } + `); + }); + + it('should resolve single field key interface with implementing object node id', async () => { + const { data, execute } = await getSchema(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Organization', data.organizations[0].id)}") { + ... on Actor { + nodeId # even though on Actor, the nodeId will be the global ID of the implementing type + name + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "name": "Foo Inc.", + "nodeId": "T3JnYW5pemF0aW9uOm8z", + }, + }, + } + `); + }); + + 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(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Story', JSON.stringify(data.stories[1]))}") { + ... on Story { + nodeId + title + content + } + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": { + "content": "Lorem ipsum dolor sit amet.", + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IlBlcnNvbmFsIFN0b3J5IDIiLCJwdWJsaXNoZWRBdCI6IjIwMTItMDItMDIifQ==", + "title": "Personal Story 2", + }, + }, + } + `); + }); + + it('should resolve node id from object', async () => { + const { execute } = await getSchema(); + + await expect( + execute({ + query: /* GraphQL */ ` + { + people { + nodeId # we omit the "id" key field making sure it's resolved internally + name + dateOfBirth + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "people": [ + { + "dateOfBirth": "2001-01-01", + "name": "John Doe", + "nodeId": "UGVyc29uOnAx", + }, + { + "dateOfBirth": "2002-02-02", + "name": "Jane Doe", + "nodeId": "UGVyc29uOnAy", + }, + ], + }, + } + `); + + await expect( + execute({ + query: /* GraphQL */ ` + { + feed { + nodeId # we omit the "title" and "publishedAt" key fields making sure it's resolved internally + } + } + `, + }), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "feed": [ + { + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IlBlcnNvbmFsIFN0b3J5IDEiLCJwdWJsaXNoZWRBdCI6IjIwMTEtMDEtMDEifQ==", + }, + { + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IlBlcnNvbmFsIFN0b3J5IDIiLCJwdWJsaXNoZWRBdCI6IjIwMTItMDItMDIifQ==", + }, + { + "nodeId": "U3Rvcnk6eyJ0aXRsZSI6IkNvcnBvcmF0ZSBTdG9yeSAzIiwicHVibGlzaGVkQXQiOiIyMDEzLTAzLTAzIn0=", + }, + ], + }, + } + `); + }); + + it('should not resolve when object doesnt exist', async () => { + const { schema } = await getSchema(); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + node(nodeId: "${toGlobalId('Person', 'IDontExist')}") { + ... on Person { + nodeId + id + name + dateOfBirth + } + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "node": null, + }, + } + `); + }); + + it('should not resolve when invalid node id', async () => { + const { schema } = await getSchema(); + + await expect( + Promise.resolve( + normalizedExecutor({ + schema, + document: parse(/* GraphQL */ ` + { + node(nodeId: "gibberish") { + ... on Organization { + nodeId + id + name + foundingDate + } + } + } + `), + }), + ), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "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, + }); + }, + }; +} diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 7589136dd..35c42aeef 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)]; @@ -211,7 +212,10 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ additionalResolvers, ); // @ts-expect-error - Typings are wrong - opts.resolvers = additionalResolvers; + opts.resolvers = opts.resolvers + ? [opts.resolvers, ...additionalResolvers] + : additionalResolvers; + // opts.resolvers = additionalResolvers; // @ts-expect-error - Typings are wrong opts.inheritResolversFromInterfaces = true; @@ -273,6 +277,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..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,6 +69,7 @@ export interface UnifiedGraphHandlerOpts { onDelegationPlanHooks?: OnDelegationPlanHook[]; onDelegationStageExecuteHooks?: OnDelegationStageExecuteHook[]; onDelegateHooks?: OnDelegateHook[]; + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; logger?: Logger; } @@ -107,6 +109,8 @@ export interface UnifiedGraphManagerOptions { instrumentation?: () => Instrumentation | undefined; onUnifiedGraphChange?(newUnifiedGraph: GraphQLSchema): void; + + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; } export type Instrumentation = { @@ -137,6 +141,9 @@ export class UnifiedGraphManager implements AsyncDisposable { private lastLoadTime?: number; private executor?: Executor; private instrumentation: () => Instrumentation | undefined; + private globalObjectIdentification: + | boolean + | GlobalObjectIdentificationOptions; constructor(private opts: UnifiedGraphManagerOptions) { this.batch = opts.batch ?? true; @@ -152,6 +159,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 +318,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..73493c219 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, @@ -193,6 +194,31 @@ 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 and `node(nodeId: ID!): Node` field to the `Query` type. + * + * ```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 + * } + * ``` + * + * @see https://graphql.org/learn/global-object-identification/ + */ + globalObjectIdentification?: boolean | GlobalObjectIdentificationOptions; } export interface GatewayConfigSubgraph< diff --git a/yarn.lock b/yarn.lock index bef19b367..d5c4c422c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3062,6 +3062,15 @@ __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: "npm:^16.11.0" + 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" @@ -5070,6 +5079,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:^" @@ -5086,6 +5096,7 @@ __metadata: "@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: @@ -14275,6 +14286,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"