diff --git a/docs/federation.md b/docs/federation.md index c485d960..8034a78f 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -1,13 +1,17 @@ # mercurius -- [Federation metadata support](#federation-metadata-support) -- [Federation with \_\_resolveReference caching](#federation-with-__resolvereference-caching) -- [Use GraphQL server as a Gateway for federated schemas](#use-graphql-server-as-a-gateway-for-federated-schemas) - - [Periodically refresh federated schemas in Gateway mode](#periodically-refresh-federated-schemas-in-gateway-mode) - - [Programmatically refresh federated schemas in Gateway mode](#programmatically-refresh-federated-schemas-in-gateway-mode) - - [Using Gateway mode with a schema registry](#using-gateway-mode-with-a-schema-registry) - - [Flag service as mandatory in Gateway mode](#flag-service-as-mandatory-in-gateway-mode) - - [Using a custom errorHandler for handling downstream service errors in Gateway mode](#using-a-custom-errorhandler-for-handling-downstream-service-errors-in-gateway-mode) +- [mercurius](#mercurius) + - [Federation](#federation) + - [Federation metadata support](#federation-metadata-support) + - [Federation with \_\_resolveReference caching](#federation-with-__resolvereference-caching) + - [Use GraphQL server as a Gateway for federated schemas](#use-graphql-server-as-a-gateway-for-federated-schemas) + - [Periodically refresh federated schemas in Gateway mode](#periodically-refresh-federated-schemas-in-gateway-mode) + - [Programmatically refresh federated schemas in Gateway mode](#programmatically-refresh-federated-schemas-in-gateway-mode) + - [Using Gateway mode with a schema registry](#using-gateway-mode-with-a-schema-registry) + - [Flag service as mandatory in Gateway mode](#flag-service-as-mandatory-in-gateway-mode) + - [Batched Queries to services](#batched-queries-to-services) + - [Using a custom errorHandler for handling downstream service errors in Gateway mode](#using-a-custom-errorhandler-for-handling-downstream-service-errors-in-gateway-mode) + - [Securely parse service responses in Gateway mode](#securely-parse-service-responses-in-gateway-mode) ## Federation @@ -351,6 +355,41 @@ server.register(mercurius, { server.listen(3002) ``` +#### Batched Queries to services + +To fully leverage the DataLoader pattern we can tell the Gateway which of its services support [batched queries](batched-queries.md). +In this case the service will receive a request body with an array of queries to execute. +Enabling batched queries for a service that doesn't support it will generate errors. + + +```js +const Fastify = require('fastify') +const mercurius = require('mercurius') + +const server = Fastify() + +server.register(mercurius, { + graphiql: true, + gateway: { + services: [ + { + name: 'user', + url: 'http://localhost:3000/graphql' + allowBatchedQueries: true + }, + { + name: 'company', + url: 'http://localhost:3001/graphql', + allowBatchedQueries: false + } + ] + }, + pollingInterval: 2000 +}) + +server.listen(3002) +``` + #### Using a custom errorHandler for handling downstream service errors in Gateway mode Service which uses Gateway mode can process different types of issues that can be obtained from remote services (for example, Network Error, Downstream Error, etc.). A developer can provide a function (`gateway.errorHandler`) that can process these errors. diff --git a/lib/gateway.js b/lib/gateway.js index 530721da..68469d04 100644 --- a/lib/gateway.js +++ b/lib/gateway.js @@ -4,8 +4,7 @@ const { getNamedType, isObjectType, isScalarType, - Kind, - parse + Kind } = require('graphql') const { Factory } = require('single-user-cache') const buildFederatedSchema = require('./federation') @@ -18,9 +17,8 @@ const { kEntityResolvers } = require('./gateway/make-resolver') const { MER_ERR_GQL_GATEWAY_REFRESH, MER_ERR_GQL_GATEWAY_INIT } = require('./errors') -const { preGatewayExecutionHandler } = require('./handlers') const findValueTypes = require('./gateway/find-value-types') - +const getQueryResult = require('./gateway/get-query-result') const allSettled = require('promise.allsettled') function isDefaultType (type) { @@ -354,72 +352,12 @@ async function buildGateway (gatewayOpts, app) { * */ factory.add(`${service}Entity`, async (queries) => { - const q = [...new Set(queries.map(q => q.query))] - - const resultIndexes = [] - let queryIndex = 0 - const mergedQueries = queries.reduce((acc, curr) => { - if (!acc[curr.query]) { - acc[curr.query] = curr.variables - resultIndexes[q.indexOf(curr.query)] = [] - } else { - acc[curr.query].representations = [ - ...acc[curr.query].representations, - ...curr.variables.representations - ] - } - - for (let i = 0; i < curr.variables.representations.length; i++) { - resultIndexes[q.indexOf(curr.query)].push(queryIndex) - } - - queryIndex++ - - return acc - }, {}) - - const result = [] - - // Gateway query here - await Promise.all(Object.entries(mergedQueries).map(async ([query, variables], queryIndex, entries) => { - // Trigger preGatewayExecution hook for entities - let modifiedQuery - if (queries[queryIndex].context.preGatewayExecution !== null) { - ({ modifiedQuery } = await preGatewayExecutionHandler({ - schema: serviceDefinition.schema, - document: parse(query), - context: queries[queryIndex].context, - service: { name: service } - })) - } - - const response = await serviceDefinition.sendRequest({ - originalRequestHeaders: queries[queryIndex].originalRequestHeaders, - body: JSON.stringify({ - query: modifiedQuery || query, - variables - }), - context: queries[queryIndex].context - }) - - let entityIndex = 0 - for (const entity of response.json.data._entities) { - if (!result[resultIndexes[queryIndex][entityIndex]]) { - result[resultIndexes[queryIndex][entityIndex]] = { - ...response, - json: { - data: { - _entities: [entity] - } - } - } - } else { - result[resultIndexes[queryIndex][entityIndex]].json.data._entities.push(entity) - } - - entityIndex++ - } - })) + // context is the same for each query, but unfortunately it's not acessible from onRequest + // where we do factory.create(). What is a cleaner option? + const context = queries[0].context + const result = await getQueryResult({ + context, queries, serviceDefinition, service + }) return result }, query => query.id) diff --git a/lib/gateway/get-query-result.js b/lib/gateway/get-query-result.js new file mode 100644 index 00000000..5e0a85c3 --- /dev/null +++ b/lib/gateway/get-query-result.js @@ -0,0 +1,177 @@ +'use strict' + +const { preGatewayExecutionHandler } = require('../handlers') + +/** + * @typedef {Object.} GroupedQueries + */ + +/** + * Group GraphQL queries by their string and map them to their variables and document. + * @param {Array} queries + * @returns {GroupedQueries} + */ +function groupQueriesByDefinition (queries) { + const q = [...new Set(queries.map(q => q.query))] + const resultIndexes = [] + const mergedQueries = queries.reduce((acc, curr, queryIndex) => { + if (!acc[curr.query]) { + acc[curr.query] = { + document: curr.document, + variables: curr.variables + } + resultIndexes[q.indexOf(curr.query)] = [] + } else { + acc[curr.query].variables.representations = [ + ...acc[curr.query].variables.representations, + ...curr.variables.representations + ] + } + + for (let i = 0; i < curr.variables.representations.length; i++) { + resultIndexes[q.indexOf(curr.query)].push(queryIndex) + } + + return acc + }, {}) + + return { mergedQueries, resultIndexes } +} + +/** + * Fetches queries result from the service with batching (1 request for all the queries). + * @param {Object} params + * @param {Object} params.service The service that will receive one request with the batched queries + * @returns {Array} result + */ +async function fetchBactchedResult ({ mergeQueriesResult, context, serviceDefinition, service }) { + const { mergedQueries, resultIndexes } = mergeQueriesResult + const batchedQueries = [] + + for (const [query, { document, variables }] of Object.entries(mergedQueries)) { + let modifiedQuery + + if (context.preGatewayExecution !== null) { + ({ modifiedQuery } = await preGatewayExecutionHandler({ + schema: serviceDefinition.schema, + document, + context, + service: { name: service } + })) + } + + batchedQueries.push({ + operationName: document.definitions.find(d => d.kind === 'OperationDefinition').name.value, + query: modifiedQuery || query, + variables + }) + } + + const response = await serviceDefinition.sendRequest({ + originalRequestHeaders: context.reply.request.headers, + body: JSON.stringify(batchedQueries), + context + }) + + return buildResult({ resultIndexes, data: response.json }) +} + +/** + * + * @param {Object} params + * @param {Array} params.resultIndexes Array used to map results with queries + * @param {Array} params.data Array of data returned from GraphQL end point + * @returns {Array} result + */ +function buildResult ({ resultIndexes, data }) { + const result = [] + + for (const [queryIndex, queryResponse] of data.entries()) { + let entityIndex = 0 + + for (const entity of queryResponse.data._entities) { + if (!result[resultIndexes[queryIndex][entityIndex]]) { + result[resultIndexes[queryIndex][entityIndex]] = { + ...queryResponse, + json: { + data: { + _entities: [entity] + } + } + } + } else { + result[resultIndexes[queryIndex][entityIndex]].json.data._entities.push(entity) + } + + entityIndex++ + } + } + + return result +} + +/** + * Fetches queries result from the service without batching (1 request for each query) + * @param {Object} params + * @param {GroupedQueries} params.mergeQueriesResult + * @param {Object} params.service The service that will receive requests for the queries + * @returns {Array} result + */ +async function fetchResult ({ mergeQueriesResult, serviceDefinition, context, service }) { + const { mergedQueries, resultIndexes } = mergeQueriesResult + const queriesEntries = Object.entries(mergedQueries) + const data = await Promise.all( + queriesEntries.map(async ([query, { document, variables }]) => { + let modifiedQuery + + if (context.preGatewayExecution !== null) { + ({ modifiedQuery } = await preGatewayExecutionHandler({ + schema: serviceDefinition.schema, + document, + context, + service: { name: service } + })) + } + + const response = await serviceDefinition.sendRequest({ + originalRequestHeaders: context.reply.request.headers, + body: JSON.stringify({ + query: modifiedQuery || query, + variables + }), + context + }) + + return response.json + }) + ) + + return buildResult({ data, resultIndexes }) +} + +/** + * Fetches queries results from their shared service and returns array of data. + * It batches queries into one request if allowBatchedQueries is true for the service. + * @param {Object} params + * @param {Array} params.queries The list of queries to be executed + * @param {Object} params.service The service to send requests to + * @returns {Array} The array of results + */ +async function getQueryResult ({ context, queries, serviceDefinition, service }) { + const mergeQueriesResult = groupQueriesByDefinition(queries) + const params = { + mergeQueriesResult, + service, + serviceDefinition, + queries, + context + } + + if (serviceDefinition.allowBatchedQueries) { + return fetchBactchedResult({ ...params }) + } + + return fetchResult({ ...params }) +} + +module.exports = getQueryResult diff --git a/lib/gateway/make-resolver.js b/lib/gateway/make-resolver.js index 118cf60c..db47537f 100644 --- a/lib/gateway/make-resolver.js +++ b/lib/gateway/make-resolver.js @@ -508,10 +508,12 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef const entityResolvers = reply.entityResolversFactory ? reply.entityResolversFactory.create() : reply[kEntityResolvers] + // This method is declared in gateway.js inside of onRequest + // hence it's unique per request. const response = await entityResolvers[`${service.name}Entity`]({ + document: operation, query, variables, - originalRequestHeaders: reply.request.headers, context, id: queryId }) diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js index 88b13cd7..82000455 100644 --- a/lib/gateway/service-map.js +++ b/lib/gateway/service-map.js @@ -221,6 +221,7 @@ async function buildServiceMap (services, errorHandler) { } serviceMap[service.name].name = service.name + serviceMap[service.name].allowBatchedQueries = service.allowBatchedQueries } await pmap(services, mapper, { concurrency: 8 }) diff --git a/test/gateway/aliases-with-batching.js b/test/gateway/aliases-with-batching.js new file mode 100644 index 00000000..a570cab5 --- /dev/null +++ b/test/gateway/aliases-with-batching.js @@ -0,0 +1,213 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + quote: (user, args, context, info) => { + return args.input + }, + metadata: (user, args, context, info) => { + return { + info: args.input + } + }, + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +test('gateway with batching - should support aliases', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const query = ` + query { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + user: { + id: 'u1', + name: 'John', + newName: 'John', + otherName: 'John', + quote: 'quote', + firstQuote: 'foo', + secondQuote: 'bar', + metadata: { + info: 'info' + }, + originalMetadata: { + hi: 'hello', + ho: 'hello' + }, + moreMetadata: { + info: 'hi' + }, + somePosts: [ + { + pid: 'p1' + } + ], + morePosts: [ + { + pid: 'p1' + }, + { + pid: 'p3' + } + ] + } + } + }) +}) diff --git a/test/gateway/batching-on-both-gateway-and-services.js b/test/gateway/batching-on-both-gateway-and-services.js new file mode 100644 index 00000000..82d26dbb --- /dev/null +++ b/test/gateway/batching-on-both-gateway-and-services.js @@ -0,0 +1,216 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + quote: (user, args, context, info) => { + return args.input + }, + metadata: (user, args, context, info) => { + return { + info: args.input + } + }, + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + allowBatchedQueries: true, + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +test('gateway with batching - should support aliases', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const query = ` + query getUser { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify([ + { operationName: 'getUser', query } + ]) + }) + + t.same(JSON.parse(res.body)[0], { + data: { + user: { + id: 'u1', + name: 'John', + newName: 'John', + otherName: 'John', + quote: 'quote', + firstQuote: 'foo', + secondQuote: 'bar', + metadata: { + info: 'info' + }, + originalMetadata: { + hi: 'hello', + ho: 'hello' + }, + moreMetadata: { + info: 'hi' + }, + somePosts: [ + { + pid: 'p1' + } + ], + morePosts: [ + { + pid: 'p1' + }, + { + pid: 'p3' + } + ] + } + } + }) +}) diff --git a/test/gateway/custom-directives-with-batching.js b/test/gateway/custom-directives-with-batching.js new file mode 100644 index 00000000..51e041d2 --- /dev/null +++ b/test/gateway/custom-directives-with-batching.js @@ -0,0 +1,286 @@ +'use strict' + +const t = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') +const { MER_ERR_GQL_GATEWAY_DUPLICATE_DIRECTIVE } = require('../../lib/errors') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +const query = ` + query { + me { + id + name + topPosts(count: 2) { + pid + author { + id + } + } + } + topPosts(count: 2) { + pid + } + } +` + +async function createUserService (directiveDefinition) { + const userServiceSchema = ` + ${directiveDefinition} + + type Query @extends { + me: User @custom + } + + type User @key(fields: "id") { + id: ID! + name: String! @custom + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + return createTestService(t, userServiceSchema, userServiceResolvers) +} + +async function createPostService (directiveDefinition) { + const postServiceSchema = ` + ${directiveDefinition} + + type Post @key(fields: "pid") { + pid: ID! @custom + author: User + } + + extend type Query { + topPosts(count: Int): [Post] + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + Post: { + __resolveReference: (post, args, context, info) => { + return posts[post.pid] + }, + author: (post, args, context, info) => { + return { + __typename: 'User', + id: post.authorId + } + } + }, + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + }, + Query: { + topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) + } + } + return createTestService(t, postServiceSchema, postServiceResolvers) +} + +t.test('gateway with batching', t => { + t.plan(2) + + t.test('should de-duplicate custom directives on the gateway', async (t) => { + t.plan(4) + + const [userService, userServicePort] = await createUserService('directive @custom(input: ID) on OBJECT | FIELD_DEFINITION') + const [postService, postServicePort] = await createPostService('directive @custom(input: ID) on OBJECT | FIELD_DEFINITION') + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + await gateway.ready() + + const userDirectiveNames = userService.graphql.schema.getDirectives().map(directive => directive.name) + t.same(userDirectiveNames, [ + 'include', + 'skip', + 'deprecated', + 'specifiedBy', + 'external', + 'requires', + 'provides', + 'key', + 'extends', + 'custom' + ]) + + const postDirectiveNames = userService.graphql.schema.getDirectives().map(directive => directive.name) + t.same(postDirectiveNames, [ + 'include', + 'skip', + 'deprecated', + 'specifiedBy', + 'external', + 'requires', + 'provides', + 'key', + 'extends', + 'custom' + ]) + + const gatewayDirectiveNames = gateway.graphql.schema.getDirectives().map(directive => directive.name) + t.same(gatewayDirectiveNames, [ + 'include', + 'skip', + 'deprecated', + 'specifiedBy', + 'custom' + ]) + + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) + }) + + t.test('should error on startup when different definitions of custom directives with the same name are present in federated services', async (t) => { + t.plan(1) + + const [userService, userServicePort] = await createUserService('directive @custom(input: ID) on OBJECT | FIELD_DEFINITION') + const [postService, postServicePort] = await createPostService('directive @custom(input: String) on OBJECT | FIELD_DEFINITION') + const serviceOpts = { + keepAliveTimeout: 10, // milliseconds + keepAliveMaxTimeout: 10 // milliseconds + } + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [ + { + ...serviceOpts, + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, + { + ...serviceOpts, + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + } + ] + } + }) + + await t.rejects(gateway.ready(), new MER_ERR_GQL_GATEWAY_DUPLICATE_DIRECTIVE('custom')) + }) +}) diff --git a/test/gateway/errors-with-batching.js b/test/gateway/errors-with-batching.js new file mode 100644 index 00000000..09063985 --- /dev/null +++ b/test/gateway/errors-with-batching.js @@ -0,0 +1,152 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') +const { ErrorWithProps } = require('../../') + +async function createTestService (t, schema, resolvers = {}, allowBatchedQueries = false) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries + }) + await service.listen(0) + return [service, service.server.address().port] +} + +async function createTestGatewayServer (t, allowBatchedQueries = false) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + throw new ErrorWithProps('Invalid User ID', { + id: 4, + code: 'USER_ID_INVALID' + }) + } + }, + User: { + quote: (user, args, context, info) => { + throw new ErrorWithProps('Invalid Quote', { + id: 4, + code: 'QUOTE_ID_INVALID' + }) + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers, allowBatchedQueries) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + throw new ErrorWithProps('Invalid Quote', { + id: 4, + code: 'NO_TOP_POSTS' + }) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers, allowBatchedQueries) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries + }] + } + }) + return gateway +} + +test('it returns the same error if batching is enabled', async (t) => { + t.plan(1) + const app1 = await createTestGatewayServer(t) + const app2 = await createTestGatewayServer(t, true) + + const query = ` + query { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res1 = await app1.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + await app1.close() + + const res2 = await app2.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res1.body), JSON.parse(res2.body)) +}) diff --git a/test/gateway/get-query-result.js b/test/gateway/get-query-result.js new file mode 100644 index 00000000..093212c3 --- /dev/null +++ b/test/gateway/get-query-result.js @@ -0,0 +1,137 @@ +'use strict' + +const { parse } = require('graphql') +const getQueryResult = require('../../lib/gateway/get-query-result') +const { test } = require('tap') + +const getQueryWithCount = (count) => ` +query EntitiesQuery($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on User { + topPosts(count: ${count}) { + pid + __typename + pid + } + } + } +} +` + +const createEntity = (pid) => ({ + __typename: 'User', + topPosts: { + pid, + __typename: 'Post' + } +}) + +const createNotBatchedResponse = (...entities) => ({ + json: { + data: { + _entities: [...entities] + } + } +}) + +const createBatchedResponse = (...entities) => ({ + json: [ + { + data: { + _entities: [...entities] + } + }, + { + data: { + _entities: [...entities] + } + } + ] +}) + +test('it works with a basic example', async (t) => { + const entity1 = createEntity('p1') + const entity2 = createEntity('p2') + const result = await getQueryResult({ + context: { + preGatewayExecution: null, + reply: { + request: { + headers: {} + } + } + }, + + queries: [ + { + document: parse(getQueryWithCount(1)), + query: getQueryWithCount(1), + variables: { + representations: [ + { + __typename: 'User', + id: 'u1' + } + ] + } + } + ], + serviceDefinition: { + sendRequest: async () => createNotBatchedResponse(entity1, entity2) + } + }) + + t.same(result[0].data._entities[0], entity1) + t.same(result[0].data._entities[1], entity2) +}) + +test('it works with a basic example and batched queries', async (t) => { + const entity1 = createEntity('p3') + const entity2 = createEntity('p4') + const result = await getQueryResult({ + context: { + preGatewayExecution: null, + reply: { + request: { + headers: {} + } + } + }, + queries: [ + { + document: parse(getQueryWithCount(1)), + query: getQueryWithCount(1), + variables: { + representations: [ + { + __typename: 'User', + id: 'u1' + } + ] + } + }, + { + document: parse(getQueryWithCount(2)), + query: getQueryWithCount(2), + variables: { + representations: [ + { + __typename: 'User', + id: 'u1' + } + ] + } + } + ], + serviceDefinition: { + allowBatchedQueries: true, + sendRequest: async () => createBatchedResponse(entity1, entity2) + } + }) + + t.same(result[0].data._entities[0], entity1) + t.same(result[0].data._entities[1], entity2) + t.same(result[1].data._entities[0], entity1) + t.same(result[1].data._entities[1], entity2) +}) diff --git a/test/gateway/hooks-with-batching.js b/test/gateway/hooks-with-batching.js new file mode 100644 index 00000000..9ff9518e --- /dev/null +++ b/test/gateway/hooks-with-batching.js @@ -0,0 +1,1240 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const { GraphQLSchema, parse } = require('graphql') +const { promisify } = require('util') +const GQL = require('../..') + +const immediate = promisify(setImmediate) + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +const query = ` + query { + me { + id + name + topPosts(count: 2) { + pid + author { + id + } + } + } + topPosts(count: 2) { + pid + } + } +` + +async function createTestGatewayServer (t, opts = {}) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type User @key(fields: "id") { + id: ID! + name: String! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + author: User + } + + extend type Query { + topPosts(count: Int): [Post] + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + Post: { + __resolveReference: (post, args, context, info) => { + return posts[post.pid] + }, + author: (post, args, context, info) => { + return { + __typename: 'User', + id: post.authorId + } + } + }, + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + }, + Query: { + topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + ...opts, + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +// ----- +// hooks +// ----- +test('gateway - hooks', async (t) => { + t.plan(32) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preParsing', async function (schema, source, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + t.ok('preParsing called') + }) + + app.graphql.addHook('preValidation', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preValidation called') + }) + + app.graphql.addHook('preExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called') + }) + + // Execution events: + // - once for user service query + // - once for post service query + // - once for reference type topPosts on User + // - once for reference type author on Post + app.graphql.addHook('preGatewayExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called') + }) + + app.graphql.addHook('onResolution', async function (execution, context) { + await immediate() + t.type(execution, 'object') + t.type(context, 'object') + t.ok('onResolution called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - hooks validation should handle invalid hook names', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + try { + app.graphql.addHook('unsupportedHook', async () => {}) + } catch (e) { + t.equal(e.message, 'unsupportedHook hook not supported!') + } +}) + +test('gateway - hooks validation should handle invalid hook name types', async (t) => { + t.plan(2) + const app = await createTestGatewayServer(t) + + try { + app.graphql.addHook(1, async () => {}) + } catch (e) { + t.equal(e.code, 'MER_ERR_HOOK_INVALID_TYPE') + t.equal(e.message, 'The hook name must be a string') + } +}) + +test('gateway - hooks validation should handle invalid hook handlers', async (t) => { + t.plan(2) + const app = await createTestGatewayServer(t) + + try { + app.graphql.addHook('preParsing', 'not a function') + } catch (e) { + t.equal(e.code, 'MER_ERR_HOOK_INVALID_HANDLER') + t.equal(e.message, 'The hook callback must be a function') + } +}) + +test('gateway - hooks should trigger when JIT is enabled', async (t) => { + t.plan(60) + const app = await createTestGatewayServer(t, { jit: 1 }) + + app.graphql.addHook('preParsing', async function (schema, source, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + t.ok('preParsing called') + }) + + // preValidation is not triggered a second time + app.graphql.addHook('preValidation', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preValidation called') + }) + + app.graphql.addHook('preExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called') + }) + + // Execution events: + // - once for user service query + // - once for post service query + // - once for reference type topPosts on User + // - once for reference type author on Post + app.graphql.addHook('preGatewayExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called') + }) + + app.graphql.addHook('onResolution', async function (execution, context) { + await immediate() + t.type(execution, 'object') + t.type(context, 'object') + t.ok('onResolution called') + }) + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) + } + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) + } +}) + +// -------------------- +// preParsing +// -------------------- +test('gateway - preParsing hooks should handle errors', async t => { + t.plan(4) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + throw new Error('a preParsing error occured') + }) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('preExecution', async (schema, operation, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a preParsing error occured' + } + ] + }) +}) + +test('gateway - preParsing hooks should be able to put values onto the context', async t => { + t.plan(8) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +// -------------- +// preValidation +// -------------- +test('gateway - preValidation hooks should handle errors', async t => { + t.plan(4) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + throw new Error('a preValidation error occured') + }) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a preValidation error occured' + } + ] + }) +}) + +test('gateway - preValidation hooks should be able to put values onto the context', async t => { + t.plan(8) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +// ------------- +// preExecution +// ------------- +test('gateway - preExecution hooks should handle errors', async t => { + t.plan(4) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + throw new Error('a preExecution error occured') + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a preExecution error occured' + } + ] + }) +}) + +test('gateway - preExecution hooks should be able to put values onto the context', async t => { + t.plan(8) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - preExecution hooks should be able to modify the request document', async t => { + t.plan(5) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called') + const documentClone = JSON.parse(JSON.stringify(document)) + documentClone.definitions[0].selectionSet.selections = [documentClone.definitions[0].selectionSet.selections[0]] + return { + document: documentClone + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + } + } + }) +}) + +test('gateway - preExecution hooks should be able to add to the errors array', async t => { + t.plan(9) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called for foo error') + return { + errors: [new Error('foo')] + } + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called for foo error') + return { + errors: [new Error('bar')] + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + }, + errors: [ + { + message: 'foo' + }, + { + message: 'bar' + } + ] + }) +}) + +// ------------------- +// preGatewayExecution +// ------------------- +test('gateway - preGatewayExecution hooks should handle errors', async t => { + t.plan(10) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + throw new Error('a preGatewayExecution error occured') + }) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.fail('this should not be called') + }) + + // This should still be called in the gateway + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + t.ok('onResolution called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: null, + topPosts: null + }, + errors: [ + { + message: 'a preGatewayExecution error occured', + locations: [{ line: 3, column: 5 }], + path: ['me'] + }, + { + message: 'a preGatewayExecution error occured', + locations: [{ line: 13, column: 5 }], + path: ['topPosts'] + } + ] + }) +}) + +test('gateway - preGatewayExecution hooks should be able to put values onto the context', async t => { + t.plan(29) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + context[document.definitions[0].name.value] = 'bar' + }) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.equal(context[document.definitions[0].name.value], 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - preGatewayExecution hooks should be able to add to the errors array', async t => { + t.plan(33) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called for foo error') + return { + errors: [new Error(`foo - ${document.definitions[0].name.value}`)] + } + }) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called for foo error') + return { + errors: [new Error(`bar - ${document.definitions[0].name.value}`)] + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + }, + errors: [ + { + message: 'foo - Query_me' + }, + { + message: 'bar - Query_me' + }, + { + message: 'foo - Query_topPosts' + }, + { + message: 'bar - Query_topPosts' + }, + { + message: 'foo - EntitiesQuery' + }, + { + message: 'bar - EntitiesQuery' + }, + { + message: 'foo - EntitiesQuery' + }, + { + message: 'bar - EntitiesQuery' + } + ] + }) +}) + +test('gateway - preGatewayExecution hooks should be able to modify the request document', async t => { + t.plan(17) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called') + if (document.definitions[0].name.value === 'EntitiesQuery') { + if (document.definitions[0].selectionSet.selections[0].selectionSet.selections[1].selectionSet.selections[0].arguments[0]) { + const documentClone = JSON.parse(JSON.stringify(document)) + documentClone.definitions[0].selectionSet.selections[0].selectionSet.selections[1].selectionSet.selections[0].arguments[0].value.value = 1 + return { + document: documentClone + } + } + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - preGatewayExecution hooks should contain service metadata', async (t) => { + t.plan(21) + const app = await createTestGatewayServer(t) + + // Execution events: + // - user service: once for user service query + // - post service: once for post service query + // - post service: once for reference type topPosts on User + // - user service: once for reference type author on Post + app.graphql.addHook('preGatewayExecution', async function (schema, document, context, service) { + await immediate() + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + if (typeof service === 'object' && service.name === 'user') { + t.equal(service.name, 'user') + } else if (typeof service === 'object' && service.name === 'post') { + t.equal(service.name, 'post') + } else { + t.fail('service metadata should be correctly populated') + return + } + t.ok('preGatewayExecution called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +// ------------- +// onResolution +// ------------- +test('gateway - onResolution hooks should handle errors', async t => { + t.plan(3) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + throw new Error('a onResolution error occured') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a onResolution error occured' + } + ] + }) +}) + +test('gateway - onResolution hooks should be able to put values onto the context', async t => { + t.plan(6) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) diff --git a/test/gateway/include-directive-with-batching.js b/test/gateway/include-directive-with-batching.js new file mode 100644 index 00000000..01630b83 --- /dev/null +++ b/test/gateway/include-directive-with-batching.js @@ -0,0 +1,214 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + metadata: (user, args, context, info) => { + return { + info: args.input + } + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +test('gateway - should support truthy include directive', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const variables = { + shouldInclude: true, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldInclude: Boolean!) { + me { + id + name + metadata(input: $input) @include(if: $shouldInclude) { + info + } + topPosts(count: 1) @include(if: $shouldInclude) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + metadata: { + info: 'hello' + }, + topPosts: [ + { + pid: 'p1' + } + ] + } + } + }) +}) + +test('gateway - should support falsy include directive', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const variables = { + shouldInclude: false, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldInclude: Boolean!) { + me { + id + name + metadata(input: $input) @include(if: $shouldInclude) { + info + } + topPosts(count: 1) @include(if: $shouldInclude) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) +}) diff --git a/test/gateway/load-balancing-with-batching.js b/test/gateway/load-balancing-with-batching.js new file mode 100644 index 00000000..a5b49b7e --- /dev/null +++ b/test/gateway/load-balancing-with-batching.js @@ -0,0 +1,198 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}, fn = async () => {}) { + const service = Fastify() + service.addHook('preHandler', fn) + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +test('load balances two peers', async (t) => { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + metadata: (user, args, context, info) => { + return { + info: args.input + } + } + } + } + let user1called = 0 + let user2called = 0 + const [userService1, userServicePort1] = await createTestService(t, userServiceSchema, userServiceResolvers, async () => { + user1called++ + }) + const [userService2, userServicePort2] = await createTestService(t, userServiceSchema, userServiceResolvers, async () => { + user2called++ + }) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService1.close() + await userService2.close() + await postService.close() + }) + + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: [`http://localhost:${userServicePort1}/graphql`, `http://localhost:${userServicePort2}/graphql`], + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + await gateway + + const variables = { + shouldSkip: true, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldSkip: Boolean!) { + me { + id + name + metadata(input: $input) @skip(if: $shouldSkip) { + info + } + topPosts(count: 1) @skip(if: $shouldSkip) { + pid + } + } + }` + + { + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) + } + + { + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) + } + + // Called two times, one to get the schema and one for the query + t.equal(user1called, 2) + + // Called one time, one one for the query + t.equal(user2called, 1) +}) diff --git a/test/gateway/with-batching.js b/test/gateway/with-batching.js new file mode 100644 index 00000000..c6db28aa --- /dev/null +++ b/test/gateway/with-batching.js @@ -0,0 +1,188 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}, allowBatchedQueries = false) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t, allowBatchedQueries = false) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + quote: (user, args, context, info) => { + return args.input + }, + metadata: (user, args, context, info) => { + return { + info: args.input + } + }, + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers, allowBatchedQueries) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers, allowBatchedQueries) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries + }] + } + }) + return gateway +} + +test('it returns the same data if batching is enabled', async (t) => { + t.plan(1) + const app1 = await createTestGatewayServer(t) + const app2 = await createTestGatewayServer(t, true) + + const query = ` + query { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res1 = await app1.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + await app1.close() + + const res2 = await app2.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res1.body), JSON.parse(res2.body)) +})