diff --git a/lib/federation.js b/lib/federation.js index 4d5461a..8d70017 100644 --- a/lib/federation.js +++ b/lib/federation.js @@ -34,7 +34,9 @@ const { isTypeDefinitionNode, isTypeExtensionNode, isObjectType, + isInterfaceType, isSpecifiedDirective, + defaultTypeResolver, GraphQLDirective } = require('graphql') const { validateSDL } = require('graphql/validation/validate') @@ -173,6 +175,35 @@ function addTypeNameToResult (result, typename) { return result } +function resolveType (reference, context, info) { + const { __typename } = reference + + const type = info.schema.getType(__typename) + + if (!type || !(isObjectType(type) || isInterfaceType(type))) { + throw new MER_ERR_GQL_FEDERATION_INVALID_SCHEMA(__typename) + } + + if (!isInterfaceType(type)) { + return type + } + + const resolveTypeFn = type.resolveType || defaultTypeResolver + + const resolveType = resolveTypeFn({ + ...reference, + __typename: undefined + }, context, info, type) + + if (typeof resolveType !== 'string' && typeof resolveType.then === 'function') { + return resolveType.then((resolvedTypename) => + info.schema.getType(resolvedTypename) + ) + } + + return info.schema.getType(resolveType) +} + function addEntitiesResolver (schema) { const entityTypes = Object.values(schema.getTypeMap()).filter( type => isObjectType(type) && typeIncludesDirective(type, 'key') @@ -199,12 +230,24 @@ function addEntitiesResolver (schema) { ...queryFields._entities, resolve: (_source, { representations }, context, info) => { return representations.map(reference => { - const { __typename } = reference + const type = resolveType(reference, context, info) + + if (type && typeof type.then === 'function') { + return type.then((resolvedType) => { + const resolveReference = resolvedType.resolveReference || + function defaultResolveReference () { + return reference + } + + const result = resolveReference(reference, {}, context, info) - const type = info.schema.getType(__typename) + if (result && typeof result.then === 'function') { + return result.then((x) => + addTypeNameToResult(x, resolvedType.name)) + } - if (!type || !isObjectType(type)) { - throw new MER_ERR_GQL_FEDERATION_INVALID_SCHEMA(__typename) + return addTypeNameToResult(result, resolvedType.name) + }) } const resolveReference = type.resolveReference @@ -216,10 +259,11 @@ function addEntitiesResolver (schema) { const result = resolveReference(reference, {}, context, info) if (result && 'then' in result && typeof result.then === 'function') { - return result.then(x => addTypeNameToResult(x, __typename)) + return result.then((x) => + addTypeNameToResult(x, type.name)) } - return addTypeNameToResult(result, __typename) + return addTypeNameToResult(result, type.name) }) } } diff --git a/test/federation.js b/test/federation.js index ec2a23c..00c0eed 100644 --- a/test/federation.js +++ b/test/federation.js @@ -1306,3 +1306,515 @@ test('federation support using schema from buildFederationSchema and custom dire res = await app.inject({ method: 'POST', url: '/graphql', body: { query } }) t.same(JSON.parse(res.body), { data: { foo: 'BAR' } }) }) + +test('entities resolver returns correct value for interface', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return 'BundleProduct' + } + }, + BundleProduct: { + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(schema) + + app.register(GQL, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface with async resolveType', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return Promise.resolve('BundleProduct') + } + }, + BundleProduct: { + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(schema) + + app.register(GQL, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface with async resolveType and resolveReference', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return Promise.resolve('BundleProduct') + } + }, + BundleProduct: { + __resolveReference: (reference) => { + return Promise.resolve({ + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + }) + } + } + } + + const federationSchema = buildFederationSchema(schema) + + app.register(GQL, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface with async without definition of resolveReference', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return Promise.resolve('BundleProduct') + } + }, + BundleProduct: {} + } + + const federationSchema = buildFederationSchema(schema) + + app.register(GQL, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1' + }] + } + }) +}) + +test('entities resolver returns correct value for interface using isTypeOf method', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type SingleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + SingleProduct: { + isTypeOf: () => { + return false + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'single' + } + } + }, + BundleProduct: { + isTypeOf: () => { + return true + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(schema) + + app.register(GQL, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface using isTypeOf as promise', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type SingleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + SingleProduct: { + isTypeOf: () => { + return Promise.resolve(false) + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'single' + } + } + }, + BundleProduct: { + isTypeOf: () => { + return Promise.resolve(true) + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(schema) + + app.register(GQL, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) diff --git a/test/federationWithGql.js b/test/federationWithGql.js index bcf1b58..816c870 100644 --- a/test/federationWithGql.js +++ b/test/federationWithGql.js @@ -1195,3 +1195,515 @@ test('buildFederationSchema with extension directive', async t => { t.fail('schema built with errors') } }) + +test('entities resolver returns correct value for interface', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return 'BundleProduct' + } + }, + BundleProduct: { + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(gql(schema)) + + app.register(mercurius, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface with async resolveType', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return Promise.resolve('BundleProduct') + } + }, + BundleProduct: { + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(gql(schema)) + + app.register(mercurius, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface with async resolveType and resolveReference', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return Promise.resolve('BundleProduct') + } + }, + BundleProduct: { + __resolveReference: (reference) => { + return Promise.resolve({ + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + }) + } + } + } + + const federationSchema = buildFederationSchema(gql(schema)) + + app.register(mercurius, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface with async without definition of resolveReference', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + Product: { + resolveType: () => { + return Promise.resolve('BundleProduct') + } + }, + BundleProduct: {} + } + + const federationSchema = buildFederationSchema(gql(schema)) + + app.register(mercurius, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1' + }] + } + }) +}) + +test('entities resolver returns correct value for interface using isTypeOf method', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type SingleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + SingleProduct: { + isTypeOf: () => { + return false + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'single' + } + } + }, + BundleProduct: { + isTypeOf: () => { + return true + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(gql(schema)) + + app.register(mercurius, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +}) + +test('entities resolver returns correct value for interface using isTypeOf as promise', async (t) => { + const app = Fastify() + const schema = ` + extend type Query { + product: Product + } + interface Product @key(fields: "id") { + id: ID! + name: String! + } + type SingleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + type BundleProduct implements Product @key(fields: "id") { + id: ID! + name: String! + productIds: [ID!]! + } + ` + + const resolvers = { + Query: { + product: () => { + return { + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + } + } + }, + SingleProduct: { + isTypeOf: () => { + return Promise.resolve(false) + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'single' + } + } + }, + BundleProduct: { + isTypeOf: () => { + return Promise.resolve(true) + }, + __resolveReference: (reference) => { + return { + id: reference.id, + name: 'bundle', + productIds: ['001', '002'] + } + } + } + } + + const federationSchema = buildFederationSchema(gql(schema)) + + app.register(mercurius, { + schema: federationSchema, + resolvers + }) + + await app.ready() + + const query = ` + { + _entities(representations: [{ __typename: "Product", id: "1"}]) { + __typename + ... on Product { + id + name + } + ... on BundleProduct { + productIds + } + } + } + ` + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _entities: [{ + __typename: 'BundleProduct', + id: '1', + name: 'bundle', + productIds: ['001', '002'] + }] + } + }) +})