From 53fe448cc82256efecdb9df6e5be5cb7a75b724e Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 4 May 2023 10:27:40 -0700 Subject: [PATCH] Resolve `Promise` references for interface types before handing off to `resolveType` fn (#2556) Since the introduction of entity interfaces, users could not return a `Promise` from `__resolveReference` while implementing a synchronous, custom `__resolveType` function. This change fixes/permits this use case. Additional background / implementation details: Returning a `Promise` from `__resolveReference` has historically never been an issue. However, with the introduction of entity interfaces, the calling of an interface's `__resolveType` function became a new concern. `__resolveType` functions expect a reference (and shouldn't be concerned with whether those references are wrapped in a `Promise`). In order to address this, we can `await` the reference before calling the `__resolveType` (this handles both the non-`Promise` and `Promise` case). --- .changeset/weak-dryers-sort.md | 21 ++++++ .../src/__tests__/executeQueryPlan.test.ts | 75 ++++++++++++++++++- .../src/__tests__/buildSubgraphSchema.test.ts | 67 +++++++++++++++++ subgraph-js/src/types.ts | 6 +- 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 .changeset/weak-dryers-sort.md diff --git a/.changeset/weak-dryers-sort.md b/.changeset/weak-dryers-sort.md new file mode 100644 index 000000000..9dabe6462 --- /dev/null +++ b/.changeset/weak-dryers-sort.md @@ -0,0 +1,21 @@ +--- +"@apollo/subgraph": patch +--- + +Resolve `Promise` references before calling `__resolveType` on interface + +Since the introduction of entity interfaces, users could not return +a `Promise` from `__resolveReference` while implementing a synchronous, +custom `__resolveType` function. This change fixes/permits this use case. + +Additional background / implementation details: + +Returning a `Promise` from `__resolveReference` has historically never +been an issue. However, with the introduction of entity interfaces, the +calling of an interface's `__resolveType` function became a new concern. + +`__resolveType` functions expect a reference (and shouldn't be concerned +with whether those references are wrapped in a `Promise`). In order to +address this, we can `await` the reference before calling the +`__resolveType` (this handles both the non-`Promise` and `Promise` case). + \ No newline at end of file diff --git a/gateway-js/src/__tests__/executeQueryPlan.test.ts b/gateway-js/src/__tests__/executeQueryPlan.test.ts index 1a63b4deb..8b13a159d 100644 --- a/gateway-js/src/__tests__/executeQueryPlan.test.ts +++ b/gateway-js/src/__tests__/executeQueryPlan.test.ts @@ -3802,10 +3802,15 @@ describe('executeQueryPlan', () => { s1, }: { s1?: { + // additional resolvers for interface I iResolversExtra?: any, + // provide a default __resolveReference for the interface hasIResolveReference?: boolean, + // turn an id into extra data returned by __resolveReference (if hasIResolveReference is true) iResolveReferenceExtra?: (id: string) => { [k: string]: any }, + // additional resolvers for type A aResolversExtra?: any, + // additional resolvers for type B bResolversExtra?: any, } }) => { @@ -4136,7 +4141,75 @@ describe('executeQueryPlan', () => { + 'Either the object returned by "I.__resolveReference" must include a valid `__typename` field, ' + 'or the "I" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.' ], - }])('resolving an interface @key $name', async ({s1, expectedErrors}) => { + }, { + name: 'with an async __resolveReference and a non-default __resolveType', + s1: { + iResolversExtra: { + async __resolveReference(ref: { id: string }) { + return ref.id === 'idA' + ? { id: ref.id, x: 1, z: 3 } + : { id: ref.id, x: 10, w: 30 }; + }, + __resolveType(ref: { id: string }) { + switch (ref.id) { + case 'idA': + return 'A'; + case 'idB': + return 'B'; + default: + throw new Error('Unknown type: ' + ref.id); + } + }, + }, + }, + }, { + name: 'with an async __resolveReference and a non-default async __resolveType', + s1: { + iResolversExtra: { + async __resolveReference(ref: { id: string }) { + return ref.id === 'idA' + ? { id: ref.id, x: 1, z: 3 } + : { id: ref.id, x: 10, w: 30 }; + }, + async __resolveType(ref: { id: string }) { + switch (ref.id) { + case 'idA': + return 'A'; + case 'idB': + return 'B'; + default: + throw new Error('Unknown type: ' + ref.id); + } + }, + }, + }, + }, { + name: 'with an async __resolveReference and async __isTypeOf on implementations', + s1: { + iResolversExtra: { + async __resolveReference(ref: { + id: string; + // I don't understand the TypeScript error that occurs when the + // return type is removed here (like all the others); it surfaces + // because `aResolversExtra` is defined, which I can't explain. + }): Promise> { + return ref.id === 'idA' + ? { id: ref.id, x: 1, z: 3 } + : { id: ref.id, x: 10, w: 30 }; + }, + }, + aResolversExtra: { + async __isTypeOf(ref: { id: string }) { + return ref.id === 'idA'; + }, + }, + bResolversExtra: { + async __isTypeOf(ref: { id: string }) { + return ref.id === 'idB'; + }, + }, + }, + }])('resolving an interface @key $name', async ({ s1, expectedErrors }) => { const tester = defineSchema({ s1 }); const { plan, response } = await tester(` diff --git a/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts b/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts index a8c8e263f..be3e7b732 100644 --- a/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts +++ b/subgraph-js/src/__tests__/buildSubgraphSchema.test.ts @@ -373,6 +373,73 @@ describe('buildSubgraphSchema', () => { expect(errors).toBeUndefined(); expect((data as any)?._entities[0].name).toEqual('Apollo Gateway'); }); + + it('correctly resolves Promise values from `resolveReference` for `resolveType`', async () => { + const query = `#graphql + query ($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on Product { + name + } + } + } + `; + + const variables = { + representations: [{ __typename: 'Product', id: 1 }], + }; + + const schema = buildSubgraphSchema([ + { + typeDefs: gql` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.4" + import: ["@key"] + ) + interface Product @key(fields: "id") { + id: ID! + name: String + } + type Book implements Product @key(fields: "id") { + id: ID! + name: String + author: String! + } + `, + resolvers: { + Product: { + async __resolveReference() { + return { id: '1', name: 'My book', author: 'Author' }; + }, + __resolveType(ref) { + if ('author' in ref) return 'Book'; + throw new Error( + 'Could not resolve type, received: ' + ref.toString(), + ); + }, + }, + }, + }, + ]); + const { data, errors } = await graphql({ + schema, + source: query, + rootValue: null, + contextValue: null, + variableValues: variables, + }); + expect(errors).toBeUndefined(); + expect(data).toMatchInlineSnapshot(` + Object { + "_entities": Array [ + Object { + "name": "My book", + }, + ], + } + `); + }); }); describe('_service root field', () => { diff --git a/subgraph-js/src/types.ts b/subgraph-js/src/types.ts index 58a040289..f8964680a 100644 --- a/subgraph-js/src/types.ts +++ b/subgraph-js/src/types.ts @@ -134,7 +134,7 @@ function ensureValidRuntimeType( return runtimeType; } -function withResolvedType({ +async function withResolvedType({ type, value, context, @@ -146,9 +146,9 @@ function withResolvedType({ context: any, info: GraphQLResolveInfo, callback: (runtimeType: GraphQLObjectType) => PromiseOrValue, -}): PromiseOrValue { +}): Promise { const resolveTypeFn = type.resolveType ?? defaultTypeResolver; - const runtimeType = resolveTypeFn(value, context, info, type); + const runtimeType = resolveTypeFn(await value, context, info, type); if (isPromise(runtimeType)) { return runtimeType.then((name) => ( callback(ensureValidRuntimeType(name, info.schema, type, value))