Repository: mercurius-js/mercurius
Pull request #313 (2020-10-09 22:10:05)

fix(gateway): make parent field use @requires directive
When extending a type defined in a separate service, the fields in the `@requires` directive are not being added to the query unless they are fetched by the client already.

This change looks at all the children of a field and adds any required fields to the parent's selection set

Example:
```
type User @key(fields: "id") @extends {
  id: ID! @external
  name: String @external
  biography: String @requires(fields: "id name")
}
```
Currently, only `id` will be passed to `biography` because it's defined on `@key` (or if it's part of the client's query)

With this change, both `id` & `name` will be passed to `biography`


Patch:
diff --git a/index.js b/index.js
index 91217bb..7317a35 100644
--- a/index.js
+++ b/index.js
@@ -123,7 +123,7 @@ const plugin = fp(async function (app, opts) {
   }
 
   if (gateway && (schema || opts.resolvers || opts.loaders)) {
-    throw new Error('Adding "schema", "resolvers", "loaders" or to plugin options when plugin is running in gateway mode is not allowed')
+    throw new Error('Adding "schema", "resolvers" or "loaders" to plugin options when plugin is running in gateway mode is not allowed')
   }
 
   if (typeof schema === 'string') {
@@ -163,7 +163,7 @@ const plugin = fp(async function (app, opts) {
           }
         }, gateway.pollingInterval)
       } else {
-        app.log.warn('\'gateway.pollingInterval\' shoud have a number type')
+        app.log.warn(`Expected a number for 'gateway.pollingInterval', received: ${typeof gateway.pollingInterval}`)
       }
     }
 
@@ -242,7 +242,7 @@ const plugin = fp(async function (app, opts) {
 
   fastifyGraphQl.extendSchema = function (s) {
     if (gateway) {
-      throw new Error('Calling extendSchema method is not allowed when plugin is running in gateway mode is not allowed')
+      throw new Error('Calling extendSchema method when plugin is running in gateway mode is not allowed')
     }
 
     if (typeof s === 'string') {
@@ -256,7 +256,7 @@ const plugin = fp(async function (app, opts) {
 
   fastifyGraphQl.defineResolvers = function (resolvers) {
     if (gateway) {
-      throw new Error('Calling defineResolvers method is not allowed when plugin is running in gateway mode is not allowed')
+      throw new Error('Calling defineResolvers method when plugin is running in gateway mode is not allowed')
     }
 
     for (const name of Object.keys(resolvers)) {
@@ -303,7 +303,7 @@ const plugin = fp(async function (app, opts) {
 
   fastifyGraphQl.defineLoaders = function (loaders) {
     if (gateway) {
-      throw new Error('Calling defineLoaders method is not allowed when plugin is running in gateway mode is not allowed')
+      throw new Error('Calling defineLoaders method when plugin is running in gateway mode is not allowed')
     }
 
     // set up the loaders factory
@@ -417,7 +417,7 @@ const plugin = fp(async function (app, opts) {
       const operationAST = getOperationAST(document, operationName)
       if (operationAST.operation !== 'query') {
         const err = new MethodNotAllowed()
-        err.errors = [new Error('Operation cannot be perfomed via a GET request')]
+        err.errors = [new Error('Operation cannot be performed via a GET request')]
         throw err
       }
     }
diff --git a/lib/gateway/make-resolver.js b/lib/gateway/make-resolver.js
index 0858ea7..7e7217a 100644
--- a/lib/gateway/make-resolver.js
+++ b/lib/gateway/make-resolver.js
@@ -30,19 +30,21 @@ function getDirectiveSelection (node, directiveName) {
 }
 
 function removeNonServiceTypeFields (selections, service, type, schema) {
-  const requiredFields = []
-
   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 && !(selection.kind === Kind.INLINE_FRAGMENT)) {
         const fieldType = getFieldType(schema, type, selection.name.value)
-        requiredFields.push(...getDirectiveSelection(fieldType.getFields && fieldType.getFields()[selection.name.value], 'requires'))
+        const requiredFields = []
+
+        for (const field of Object.values(fieldType.getFields())) {
+          requiredFields.push(...getDirectiveSelection(field, 'requires'))
+        }
 
         return {
           ...selection,
           selectionSet: {
             kind: Kind.SELECTION_SET,
-            selections: removeNonServiceTypeFields(selection.selectionSet.selections, service, fieldType, schema)
+            selections: removeNonServiceTypeFields([...selection.selectionSet.selections, ...requiredFields], service, fieldType, schema)
           }
         }
       }
@@ -58,8 +60,7 @@ function removeNonServiceTypeFields (selections, service, type, schema) {
       arguments: [],
       directives: []
     },
-    ...getDirectiveSelection(type, 'key'),
-    ...requiredFields
+    ...getDirectiveSelection(type, 'key')
   ]
 }
 


Test patch:
diff --git a/tap-snapshots/test-routes.js-TAP.test.js b/tap-snapshots/test-routes.js-TAP.test.js
index f943bbc..9c18269 100644
--- a/tap-snapshots/test-routes.js-TAP.test.js
+++ b/tap-snapshots/test-routes.js-TAP.test.js
@@ -95,7 +95,7 @@ exports['test/routes.js TAP mutation with GET errors > must match snapshot 1'] =
   "data": null,
   "errors": [
     {
-      "message": "Operation cannot be perfomed via a GET request"
+      "message": "Operation cannot be performed via a GET request"
     }
   ]
 }
diff --git a/test/gateway/config.js b/test/gateway/config.js
index dfbe607..957d2dd 100644
--- a/test/gateway/config.js
+++ b/test/gateway/config.js
@@ -4,7 +4,7 @@ const { test } = require('tap')
 const Fastify = require('fastify')
 const GQL = require('../..')
 
-test('"schema" option not allowed in gateway moode', async (t) => {
+test('"schema" option not allowed in gateway mode', async (t) => {
   const app = Fastify()
   const schema = `
     type Query {
@@ -22,11 +22,11 @@ test('"schema" option not allowed in gateway moode', async (t) => {
   try {
     await app.ready()
   } catch (err) {
-    t.is(err.message, 'Adding "schema", "resolvers", "loaders" or to plugin options when plugin is running in gateway mode is not allowed')
+    t.is(err.message, 'Adding "schema", "resolvers" or "loaders" to plugin options when plugin is running in gateway mode is not allowed')
   }
 })
 
-test('"resolvers" option not allowed in gateway moode', async (t) => {
+test('"resolvers" option not allowed in gateway mode', async (t) => {
   const app = Fastify()
 
   app.register(GQL, {
@@ -39,11 +39,11 @@ test('"resolvers" option not allowed in gateway moode', async (t) => {
   try {
     await app.ready()
   } catch (err) {
-    t.is(err.message, 'Adding "schema", "resolvers", "loaders" or to plugin options when plugin is running in gateway mode is not allowed')
+    t.is(err.message, 'Adding "schema", "resolvers" or "loaders" to plugin options when plugin is running in gateway mode is not allowed')
   }
 })
 
-test('"loaders" option not allowed in gateway moode', async (t) => {
+test('"loaders" option not allowed in gateway mode', async (t) => {
   const app = Fastify()
 
   app.register(GQL, {
@@ -56,6 +56,6 @@ test('"loaders" option not allowed in gateway moode', async (t) => {
   try {
     await app.ready()
   } catch (err) {
-    t.is(err.message, 'Adding "schema", "resolvers", "loaders" or to plugin options when plugin is running in gateway mode is not allowed')
+    t.is(err.message, 'Adding "schema", "resolvers" or "loaders" to plugin options when plugin is running in gateway mode is not allowed')
   }
 })
diff --git a/test/gateway/methods.js b/test/gateway/methods.js
index 8bd647a..024bbef 100644
--- a/test/gateway/methods.js
+++ b/test/gateway/methods.js
@@ -54,7 +54,7 @@ test('calling defineLoaders throws an error in gateway mode', async (t) => {
       }
     })
   } catch (err) {
-    t.is(err.message, 'Calling defineLoaders method is not allowed when plugin is running in gateway mode is not allowed')
+    t.is(err.message, 'Calling defineLoaders method when plugin is running in gateway mode is not allowed')
   }
 })
 
@@ -93,7 +93,7 @@ test('calling defineResolvers throws an error in gateway mode', async (t) => {
       }
     })
   } catch (err) {
-    t.is(err.message, 'Calling defineResolvers method is not allowed when plugin is running in gateway mode is not allowed')
+    t.is(err.message, 'Calling defineResolvers method when plugin is running in gateway mode is not allowed')
   }
 })
 
@@ -132,7 +132,7 @@ test('calling extendSchema throws an error in gateway mode', async (t) => {
       }
     `)
   } catch (err) {
-    t.is(err.message, 'Calling extendSchema method is not allowed when plugin is running in gateway mode is not allowed')
+    t.is(err.message, 'Calling extendSchema method when plugin is running in gateway mode is not allowed')
     t.end()
   }
 })
diff --git a/test/gateway/requires-directive.js b/test/gateway/requires-directive.js
new file mode 100644
index 0000000..3a3a2fb
--- /dev/null
+++ b/test/gateway/requires-directive.js
@@ -0,0 +1,156 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const GQL = require('../..')
+
+async function createService (t, schema, resolvers = {}) {
+  const service = Fastify()
+  t.tearDown(() => {
+    service.close()
+  })
+  service.register(GQL, {
+    schema,
+    resolvers,
+    federationMetadata: true
+  })
+  await service.listen(0)
+
+  return 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: 'u2'
+  }
+}
+
+test('gateway handles @requires directive correctly', async (t) => {
+  const userServicePort = await createService(t, `
+    extend type Query {
+      me: User
+    }
+
+    type User @key(fields: "id") {
+      id: ID!
+      name: String!
+      fullName: String
+      avatar(size: AvatarSize!): String
+      friends: [User]
+    }
+
+    enum AvatarSize {
+      small
+      medium
+      large
+    }
+  `, {
+    Query: {
+      me: (root, args, context, info) => {
+        return users.u1
+      }
+    },
+    User: {
+      __resolveReference: (user, args, context, info) => {
+        return users[user.id]
+      },
+      avatar: (user, { size }) => `avatar-${size}.jpg`,
+      friends: (user) => Object.values(users).filter(u => u.id !== user.id),
+      fullName: (user) => user.name + ' Doe'
+    }
+  })
+
+  const biographyServicePort = await createService(t, `
+    type User @key(fields: "id") @extends {
+      id: ID! @external
+      name: String @external
+      biography: String @requires(fields: "id name")
+    }
+  `, {
+    User: {
+      biography (user) {
+        const numberOfPosts = Object.values(posts).filter(p => p.authorId === user.id).length
+        return `${user.name} has ${numberOfPosts} ${numberOfPosts === 1 ? 'post' : 'posts'}`
+      }
+    }
+  })
+
+  const gateway = Fastify()
+  t.tearDown(() => {
+    gateway.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 query = `
+    query {
+      me {
+        friends {
+          avatar(size:small)
+          biography
+        }
+      }
+    }   
+  `
+
+  const res = await gateway.inject({
+    method: 'POST',
+    headers: {
+      'content-type': 'application/json'
+    },
+    url: '/graphql',
+    body: JSON.stringify({
+      query
+    })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        friends: [
+          {
+            avatar: 'avatar-small.jpg',
+            biography: 'Jane has 2 posts'
+          }
+        ]
+      }
+    }
+  })
+})