diff --git a/bench.sh b/bench.sh index 9202e4ee..d76a5d5c 100755 --- a/bench.sh +++ b/bench.sh @@ -1,3 +1,19 @@ #! /bin/bash -./node_modules/.bin/autocannon -c 100 -d 5 -p 10 --on-port '/graphql?query={add(x:2,y:2)}' -- node examples/basic.js +# from https://github.com/mercurius-js/auth/tree/main/bench + +echo '==============================' +echo '= Normal Mode =' +echo '==============================' +npx concurrently --raw -k \ + "node ./bench/standalone.js" \ + "npx wait-on tcp:3000 && node ./bench/standalone-bench.js" + +echo '===============================' +echo '= Gateway Mode =' +echo '===============================' +npx concurrently --raw -k \ + "node ./bench/gateway-service-1.js" \ + "node ./bench/gateway-service-2.js" \ + "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway.js" \ + "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" diff --git a/bench/gateway-bench.js b/bench/gateway-bench.js new file mode 100644 index 00000000..524ea97f --- /dev/null +++ b/bench/gateway-bench.js @@ -0,0 +1,44 @@ +'use strict' + +const autocannon = require('autocannon') + +const query = `query { + me { + id + name + nickname: name + topPosts(count: 2) { + pid + author { + id + } + } + } + topPosts(count: 2) { + pid + } +}` + +const instance = autocannon( + { + url: 'http://localhost:3000/graphql', + connections: 100, + title: '', + method: 'POST', + headers: { + 'content-type': 'application/json', 'x-user': 'admin' + }, + body: JSON.stringify({ query }) + }, + (err) => { + if (err) { + console.error(err) + } + } +) + +process.once('SIGINT', () => { + instance.stop() +}) + +autocannon.track(instance, { renderProgressBar: true }) diff --git a/bench/gateway-service-1.js b/bench/gateway-service-1.js new file mode 100644 index 00000000..bf3e5814 --- /dev/null +++ b/bench/gateway-service-1.js @@ -0,0 +1,61 @@ +'use strict' + +const Fastify = require('fastify') +const mercurius = require('..') + +const app = Fastify() + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const schema = ` +directive @auth( + requires: Role = ADMIN, +) on OBJECT | FIELD_DEFINITION + +enum Role { + ADMIN + REVIEWER + USER + UNKNOWN +} + +type Query @extends { + me: User +} + +type User @key(fields: "id") { + id: ID! + name: String! @auth(requires: ADMIN) +}` + +const resolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } +} + +app.register(mercurius, { + schema, + resolvers, + federationMetadata: true, + graphiql: false, + jit: 1 +}) + +app.listen(3001) diff --git a/bench/gateway-service-2.js b/bench/gateway-service-2.js new file mode 100644 index 00000000..d5236074 --- /dev/null +++ b/bench/gateway-service-2.js @@ -0,0 +1,91 @@ +'use strict' + +const Fastify = require('fastify') +const mercurius = require('..') + +const app = Fastify() + +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 schema = ` +directive @auth( + requires: Role = ADMIN, +) on OBJECT | FIELD_DEFINITION + +enum Role { + ADMIN + REVIEWER + USER + UNKNOWN +} + +type Post @key(fields: "pid") { + pid: ID! + author: User @auth(requires: ADMIN) +} + +extend type Query { + topPosts(count: Int): [Post] @auth(requires: ADMIN) +} + +type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] +}` + +const resolvers = { + 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) + } +} + +app.register(mercurius, { + schema, + resolvers, + federationMetadata: true, + graphiql: false, + jit: 1 +}) + +app.listen(3002) diff --git a/bench/gateway.js b/bench/gateway.js new file mode 100644 index 00000000..bb50fc7b --- /dev/null +++ b/bench/gateway.js @@ -0,0 +1,22 @@ +'use strict' + +const Fastify = require('fastify') +const mercurius = require('..') + +const app = Fastify() + +app.register(mercurius, { + gateway: { + services: [{ + name: 'user', + url: 'http://localhost:3001/graphql' + }, { + name: 'post', + url: 'http://localhost:3002/graphql' + }] + }, + graphiql: false, + jit: 1 +}) + +app.listen(3000) diff --git a/bench/standalone-bench.js b/bench/standalone-bench.js new file mode 100644 index 00000000..de8b0d96 --- /dev/null +++ b/bench/standalone-bench.js @@ -0,0 +1,43 @@ +'use strict' + +const autocannon = require('autocannon') + +const query = `query { + four: add(x: 2, y: 2) + six: add(x: 3, y: 3) + subtract(x: 3, y: 3) + messages { + title + public + private + } + adminMessages { + title + public + private + } +}` + +const instance = autocannon( + { + url: 'http://localhost:3000/graphql', + connections: 100, + title: '', + method: 'POST', + headers: { + 'content-type': 'application/json', 'x-user': 'admin' + }, + body: JSON.stringify({ query }) + }, + (err) => { + if (err) { + console.error(err) + } + } +) + +process.once('SIGINT', () => { + instance.stop() +}) + +autocannon.track(instance, { renderProgressBar: true }) diff --git a/bench/standalone-setup.js b/bench/standalone-setup.js new file mode 100644 index 00000000..ba75220e --- /dev/null +++ b/bench/standalone-setup.js @@ -0,0 +1,73 @@ +'use strict' + +const schema = ` + directive @auth( + requires: Role = ADMIN, + ) on OBJECT | FIELD_DEFINITION + + enum Role { + ADMIN + REVIEWER + USER + UNKNOWN + } + + type Message { + title: String! + public: String! + private: String! @auth(requires: ADMIN) + } + + type Query { + add(x: Int, y: Int): Int @auth(requires: ADMIN) + subtract(x: Int, y: Int): Int + messages: [Message!]! + adminMessages: [Message!]! @auth(requires: ADMIN) + } +` + +const resolvers = { + Query: { + add: async (_, obj) => { + const { x, y } = obj + return x + y + }, + subtract: async (_, obj) => { + const { x, y } = obj + return x - y + }, + messages: async () => { + return [ + { + title: 'one', + public: 'public one', + private: 'private one' + }, + { + title: 'two', + public: 'public two', + private: 'private two' + } + ] + }, + adminMessages: async () => { + return [ + { + title: 'admin one', + public: 'admin public one', + private: 'admin private one' + }, + { + title: 'admin two', + public: 'admin public two', + private: 'admin private two' + } + ] + } + } +} + +module.exports = { + schema, + resolvers +} diff --git a/bench/standalone.js b/bench/standalone.js new file mode 100644 index 00000000..a1a80379 --- /dev/null +++ b/bench/standalone.js @@ -0,0 +1,16 @@ +'use strict' + +const Fastify = require('fastify') +const mercurius = require('..') +const { schema, resolvers } = require('./standalone-setup') + +const app = Fastify() + +app.register(mercurius, { + schema, + resolvers, + graphiql: false, + jit: 1 +}) + +app.listen(3000) diff --git a/lib/federation.js b/lib/federation.js index c7c080af..7070d56e 100644 --- a/lib/federation.js +++ b/lib/federation.js @@ -37,6 +37,7 @@ const { const { validateSDL } = require('graphql/validation/validate') const compositionRules = require('./federation/compositionRules') const { MER_ERR_GQL_INVALID_SCHEMA, MER_ERR_GQL_GATEWAY_INVALID_SCHEMA } = require('./errors') +const { hasExtensionDirective } = require('./util') const BASE_FEDERATION_TYPES = ` scalar _Any @@ -81,13 +82,6 @@ function removeExternalFields (typeDefinition) { } } -function hasExtendsDirective (definition) { - if (!definition.directives) { - return false - } - return definition.directives.some(directive => directive.name.value === 'extends') -} - function getStubTypes (schemaDefinitions, isGateway) { const definitionsMap = {} const extensionsMap = {} @@ -96,7 +90,7 @@ function getStubTypes (schemaDefinitions, isGateway) { for (const definition of schemaDefinitions) { const typeName = definition.name.value - const isTypeExtensionByDirective = hasExtendsDirective(definition) + const isTypeExtensionByDirective = hasExtensionDirective(definition) /* istanbul ignore else we are not interested in nodes that does not match if statements */ if (isTypeDefinitionNode(definition) && !isTypeExtensionByDirective) { definitionsMap[typeName] = definition @@ -128,10 +122,12 @@ function getStubTypes (schemaDefinitions, isGateway) { function gatherDirectives (type) { let directives = [] - for (const node of (type.extensionASTNodes || [])) { + if (type.extensionASTNodes) { + for (const node of type.extensionASTNodes) { /* istanbul ignore else we are not interested in nodes that does not have directives */ - if (node.directives) { - directives = directives.concat(node.directives) + if (node.directives) { + directives = directives.concat(node.directives) + } } } diff --git a/lib/gateway/make-resolver.js b/lib/gateway/make-resolver.js index 58f9ea3d..ceb4ecc2 100644 --- a/lib/gateway/make-resolver.js +++ b/lib/gateway/make-resolver.js @@ -38,7 +38,34 @@ function getDirectiveSelection (node, directiveName) { return query.definitions[0].selectionSet.selections } -function removeNonServiceTypeFields (selections, service, type, schema) { +function getDirectiveRequiresSelection (selections, type) { + if (!type.extensionASTNodes || + !type.extensionASTNodes[0].fields[0] || + !type.extensionASTNodes[0].fields[0].directives[0]) { + return [] + } + + const requires = [] + const selectedFields = selections.map(selection => selection.name.value) + + for (let i = 0; i < type.extensionASTNodes.length; i++) { + for (let j = 0; j < type.extensionASTNodes[i].fields.length; j++) { + const field = type.extensionASTNodes[i].fields[j] + if (!selectedFields.includes(field.name.value) || !field.directives) { + continue + } + const directive = field.directives.find(d => d.name.value === 'requires') + if (!directive) { continue } + // assumes arguments is always present, might require a custom error in case it is not + const query = parse(`{ ${directive.arguments[0].value.value} }`) + requires.push(...query.definitions[0].selectionSet.selections) + } + } + + return requires +} + +function collectServiceTypeFields (selections, service, type, schema) { return [ ...selections.filter(selection => selection.kind === Kind.INLINE_FRAGMENT || selection.kind === Kind.FRAGMENT_SPREAD || service.typeMap[type].has(selection.name.value)).map(selection => { if (selection.selectionSet && selection.selectionSet.selections) { @@ -54,7 +81,7 @@ function removeNonServiceTypeFields (selections, service, type, schema) { ...selection, selectionSet: { kind: Kind.SELECTION_SET, - selections: removeNonServiceTypeFields([...selection.selectionSet.selections, ...requiredFields], service, inlineFragmentType, schema) + selections: collectServiceTypeFields([...selection.selectionSet.selections, ...requiredFields], service, inlineFragmentType, schema) } } } @@ -72,7 +99,7 @@ function removeNonServiceTypeFields (selections, service, type, schema) { ...selection, selectionSet: { kind: Kind.SELECTION_SET, - selections: removeNonServiceTypeFields([...selection.selectionSet.selections, ...requiredFields], service, fieldType, schema) + selections: collectServiceTypeFields([...selection.selectionSet.selections, ...requiredFields], service, fieldType, schema) } } } @@ -88,7 +115,8 @@ function removeNonServiceTypeFields (selections, service, type, schema) { arguments: [], directives: [] }, - ...getDirectiveSelection(type, 'key') + ...getDirectiveSelection(type, 'key'), + ...getDirectiveRequiresSelection(selections, type) ] } @@ -317,7 +345,7 @@ function collectFragmentsToInclude (usedFragments, fragments, service, schema) { for (const fragmentName of usedFragments) { visitedFragments.add(fragmentName) const fragment = fragments[fragmentName] - const selections = removeNonServiceTypeFields(fragment.selectionSet.selections, service, fragment.typeCondition.name.value, schema) + const selections = collectServiceTypeFields(fragment.selectionSet.selections, service, fragment.typeCondition.name.value, schema) result.push({ ...fragment, @@ -387,7 +415,7 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef operation = cached.operation } else { // Remove items from selections that are not defined in the service - const selections = fieldNodes[0].selectionSet ? removeNonServiceTypeFields(fieldNodes[0].selectionSet.selections, service, type, schema) : [] + const selections = fieldNodes[0].selectionSet ? collectServiceTypeFields(fieldNodes[0].selectionSet.selections, service, type, schema) : [] // collect all variable names that are used in selection variableNamesToDefine = new Set(collectArgumentsWithVariableValues(selections)) diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js index 8f6fa777..33001db2 100644 --- a/lib/gateway/service-map.js +++ b/lib/gateway/service-map.js @@ -13,11 +13,7 @@ const { buildRequest, sendRequest } = require('./request') const SubscriptionClient = require('../subscription-client') const { MER_ERR_GQL_GATEWAY_INIT } = require('../errors') const buildFederationSchema = require('../federation') - -function hasDirective (directiveName, node) { - const { directives = [] } = node - return directives.some(directive => directive.name.value === directiveName) -} +const { hasDirective, hasExtensionDirective } = require('../util') function createFieldSet (existingSet, definition, filterFn = () => false) { const fieldsSet = existingSet || new Set() @@ -40,7 +36,7 @@ function createTypeMap (schemaDefinition) { const extensionTypeMap = {} for (const definition of parsedSchema.definitions) { - const isTypeExtensionByDirective = hasDirective('extends', definition) + const isTypeExtensionByDirective = hasExtensionDirective(definition) /* istanbul ignore else we are only interested in type definition and type extension scenarios */ if (isTypeDefinitionNode(definition) && !isTypeExtensionByDirective) { typeMap[definition.name.value] = createFieldSet(typeMap[definition.name.value], definition) diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 00000000..8a836203 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,29 @@ +'use strict' + +function hasDirective (directiveName, node) { + if (!node.directives || node.directives.length < 1) { + return false + } + for (let i = 0; i < node.directives.length; i++) { + if (node.directives[i].name.value === directiveName) { + return true + } + } +} + +function hasExtensionDirective (node) { + if (!node.directives || node.directives.length < 1) { + return false + } + for (let i = 0; i < node.directives.length; i++) { + const directive = node.directives[i].name.value + if (directive === 'extends' || directive === 'requires') { + return true + } + } +} + +module.exports = { + hasDirective, + hasExtensionDirective +} diff --git a/package.json b/package.json index e3155f44..d27c94bf 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/parser": "^3.6.1", "autocannon": "^7.0.5", + "concurrently": "^6.0.2", "docsify-cli": "^4.4.1", "fastify": "^3.0.2", "graphql-middleware": "^6.0.2", @@ -46,7 +47,8 @@ "standard": "^16.0.0", "tap": "^15.0.0", "tsd": "^0.14.0", - "typescript": "^4.0.3" + "typescript": "^4.0.3", + "wait-on": "^5.3.0" }, "dependencies": { "@types/isomorphic-form-data": "^2.0.0", diff --git a/test/gateway/requires-directive.js b/test/gateway/requires-directive.js index 77ddb14d..5cf35544 100644 --- a/test/gateway/requires-directive.js +++ b/test/gateway/requires-directive.js @@ -4,7 +4,7 @@ const { test } = require('tap') const Fastify = require('fastify') const GQL = require('../..') -async function createService (t, schema, resolvers = {}) { +async function createService (schema, resolvers = {}) { const service = Fastify() service.register(GQL, { schema, @@ -13,43 +13,79 @@ async function createService (t, schema, resolvers = {}) { }) await service.listen(0) - return [service, service.server.address().port] + return service } -const users = { - u1: { - id: 'u1', - name: 'John' - }, - u2: { - id: 'u2', - name: 'Jane' +async function createGateway (...services) { + const gateway = Fastify() + const teardown = async () => { + await gateway.close() + for (const service of services) { + await service.close() + } } + const servicesMap = services.map((service, i) => ({ + name: `service${i}`, + url: `http://localhost:${service.server.address().port}/graphql` + })) + + gateway.register(GQL, { + gateway: { + services: servicesMap + } + }) + + await gateway.listen(0) + return { gateway, teardown } } -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: 'u2' - } +function gatewayRequest (gateway, query) { + return gateway.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + url: '/graphql', + body: JSON.stringify({ + query + }) + }) } test('gateway handles @requires directive correctly', async (t) => { - const [userService, userServicePort] = await createService(t, ` + 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: 'u2' + } + } + + const userService = await createService(` extend type Query { me: User } @@ -83,7 +119,7 @@ test('gateway handles @requires directive correctly', async (t) => { } }) - const [biographyService, biographyServicePort] = await createService(t, ` + const biographyService = await createService(` type User @key(fields: "id") @extends { id: ID! @external name: String @external @@ -98,25 +134,8 @@ test('gateway handles @requires directive correctly', async (t) => { } }) - const gateway = Fastify() - t.teardown(async () => { - await gateway.close() - await biographyService.close() - await userService.close() - }) - gateway.register(GQL, { - gateway: { - services: [{ - name: 'post', - url: `http://localhost:${userServicePort}/graphql` - }, { - name: 'rating', - url: `http://localhost:${biographyServicePort}/graphql` - }] - } - }) - - await gateway.listen(0) + const { gateway, teardown } = await createGateway(biographyService, userService) + t.teardown(teardown) const query = ` query { @@ -128,17 +147,7 @@ test('gateway handles @requires directive correctly', async (t) => { } } ` - - const res = await gateway.inject({ - method: 'POST', - headers: { - 'content-type': 'application/json' - }, - url: '/graphql', - body: JSON.stringify({ - query - }) - }) + const res = await gatewayRequest(gateway, query) t.same(JSON.parse(res.body), { data: { @@ -153,3 +162,290 @@ test('gateway handles @requires directive correctly', async (t) => { } }) }) + +test('gateway handles @requires directive correctly from different services', async (t) => { + const regions = [{ + id: 1, + city: 'London' + }, { + id: 2, + city: 'Paris' + }] + + const sizes = [{ + id: 1, + cpus: 10, + memory: 10 + }, { + id: 2, + cpus: 20, + memory: 20 + }, { + id: 3, + cpus: 30, + memory: 30 + }] + + const dictService = await createService(` + type Size @key(fields: "id") { + id: Int! + cpus: Int! + memory: Int! + } + + type Region @key(fields: "id") { + id: Int! + city: String! + } + + extend type Host @key(fields: "id") { + id: Int! @external + size: Int! @external + region: Int! @external + sizeData: Size! @requires(fields: "size") + regionData: Region! @requires(fields: "region") + }`, { + Host: { + regionData (host) { + return host.region && regions.find(r => r.id === host.region) + }, + sizeData (host) { + return host.size && sizes.find(s => s.id === host.size) + } + } + }) + + const hosts = [{ + id: 1, + name: 'test1', + region: 1, + size: 1 + }, { + id: 1, + name: 'test2', + region: 2, + size: 2 + }] + + const hostService = await createService(` + extend type Query { + hosts: [Host] + } + + type Host @key(fields: "id") { + id: Int! + name: String! + region: Int! + size: Int! + }`, { + Query: { + hosts (parent, args, context, info) { + return hosts + } + } + }) + + const { gateway, teardown } = await createGateway(hostService, dictService) + t.teardown(teardown) + + t.plan(2) + + t.test('should retrieve @requires fields from different services', async (t) => { + const query = ` + query { + hosts { + name + sizeData { + cpus + } + } + }` + const res = await gatewayRequest(gateway, query) + t.same(JSON.parse(res.body), { + data: { + hosts: [ + { + name: 'test1', + sizeData: { + cpus: 10 + } + }, + { + name: 'test2', + sizeData: { + cpus: 20 + } + } + ] + } + }) + }) + + t.test('should retrieve multiple @requires fields from different services', async (t) => { + const query = ` + query { + hosts { + name + sizeData { + cpus + }, + regionData { + city + } + } + }` + const res = await gatewayRequest(gateway, query) + t.same(JSON.parse(res.body), { + data: { + hosts: [ + { + name: 'test1', + sizeData: { + cpus: 10 + }, + regionData: { + city: 'London' + } + }, + { + name: 'test2', + sizeData: { + cpus: 20 + }, + regionData: { + city: 'Paris' + } + } + ] + } + }) + }) +}) + +test('gateway handles @requires directive correctly apart of other directives', async (t) => { + const regions = [{ + id: 1, + city: 'London' + }, { + id: 2, + city: 'Paris' + }] + + const sizes = [{ + id: 1, + cpus: 10, + memory: 10 + }, { + id: 2, + cpus: 20, + memory: 20 + }, { + id: 3, + cpus: 30, + memory: 30 + }] + + const dictService = await createService(` + directive @custom on OBJECT | FIELD_DEFINITION + + type Size @key(fields: "id") { + id: Int! + cpus: Int! + memory: Int! + } + + type Region @key(fields: "id") { + id: Int! + city: String! + } + + type Metadata @key(fields: "id") { + id: Int! + description: String! + } + + extend type Host @key(fields: "id") { + id: Int! @external + size: Int! @external + region: Int! @external + metadata: Metadata @custom + sizeData: Size! @requires(fields: "size") + regionData: Region! @requires(fields: "region") + }`, { + Host: { + regionData (host) { + return host.region && regions.find(r => r.id === host.region) + }, + sizeData (host) { + return host.size && sizes.find(s => s.id === host.size) + } + } + }) + + const hosts = [{ + id: 1, + name: 'test1', + region: 1, + size: 1 + }, { + id: 1, + name: 'test2', + region: 2, + size: 2 + }] + + const hostService = await createService(` + extend type Query { + hosts: [Host] + } + + type Host @key(fields: "id") { + id: Int! + name: String! + region: Int! + size: Int! + }`, { + Query: { + hosts (parent, args, context, info) { + return hosts + } + } + }) + + const { gateway, teardown } = await createGateway(hostService, dictService) + t.teardown(teardown) + + const query = ` + query { + hosts { + name + sizeData { + cpus + } + metadata { + description + } + } + }` + const res = await gatewayRequest(gateway, query) + t.same(JSON.parse(res.body), { + data: { + hosts: [ + { + name: 'test1', + sizeData: { + cpus: 10 + }, + metadata: null + }, + { + name: 'test2', + sizeData: { + cpus: 20 + }, + metadata: null + } + ] + } + }) +})