Repository: mcollina/fastify-gql
Pull request #160 (2020-05-11 12:03:18)

Add @extends directive support
Add support for `@extends` directive to federated services. With this change, it is possible to define a type extension in the following way:

```
type User @key(fields: "id") @extends {
  id: ID! @external
  post: Post
}
```

This is equivalent to the following:

```
extend type User @key(fields: "id") {
  id: ID! @external
  post: Post
}
```

This change is needed to be able to support federated services that are implemented with a graphql library that doesn't support the `extend` keyword (such as `graphql-java`).


Patch:
diff --git a/examples/gateway.js b/examples/gateway.js
index 453d8e5..2e20441 100644
--- a/examples/gateway.js
+++ b/examples/gateway.js
@@ -102,11 +102,11 @@ async function start () {
       author: User @requires(fields: "pid title")
     }
 
-    extend type Query {
+    type Query @extends {
       topPosts(count: Int): [Post]
     }
 
-    extend type User @key(fields: "id") {
+    type User @key(fields: "id") @extends {
       id: ID! @external
       name: String @external
       posts: [Post]
diff --git a/lib/federation.js b/lib/federation.js
index c007303..f7531f5 100644
--- a/lib/federation.js
+++ b/lib/federation.js
@@ -43,6 +43,7 @@ const BASE_FEDERATION_TYPES = `
   directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
   directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
   directive @key(fields: _FieldSet!) on OBJECT | INTERFACE
+  directive @extends on OBJECT | INTERFACE
 `
 
 const FEDERATION_SCHEMA = `
@@ -61,6 +62,15 @@ const extensionKindToDefinitionKind = {
   [Kind.INPUT_OBJECT_TYPE_EXTENSION]: Kind.INPUT_OBJECT_TYPE_DEFINITION
 }
 
+const definitionKindToExtensionKind = {
+  [Kind.SCALAR_TYPE_DEFINITION]: Kind.SCALAR_TYPE_EXTENSION,
+  [Kind.OBJECT_TYPE_DEFINITION]: Kind.OBJECT_TYPE_EXTENSION,
+  [Kind.INTERFACE_TYPE_DEFINITION]: Kind.INTERFACE_TYPE_EXTENSION,
+  [Kind.UNION_TYPE_DEFINITION]: Kind.UNION_TYPE_EXTENSION,
+  [Kind.ENUM_TYPE_DEFINITION]: Kind.ENUM_TYPE_EXTENSION,
+  [Kind.INPUT_OBJECT_TYPE_DEFINITION]: Kind.INPUT_OBJECT_TYPE_EXTENSION
+}
+
 function removeExternalFields (typeDefinition) {
   return {
     ...typeDefinition,
@@ -68,6 +78,13 @@ 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 = {}
@@ -76,15 +93,18 @@ function getStubTypes (schemaDefinitions, isGateway) {
 
   for (const definition of schemaDefinitions) {
     const typeName = definition.name.value
-
+    const isTypeExtensionByDirective = hasExtendsDirective(definition)
     /* istanbul ignore else we are not interested in nodes that does not match if statements */
-    if (isTypeDefinitionNode(definition)) {
+    if (isTypeDefinitionNode(definition) && !isTypeExtensionByDirective) {
       definitionsMap[typeName] = definition
-    } else if (isTypeExtensionNode(definition)) {
+    } else if (isTypeExtensionNode(definition) || (isTypeDefinitionNode(definition) && isTypeExtensionByDirective)) {
       extensionsMap[typeName] = {
-        kind: extensionKindToDefinitionKind[definition.kind],
+        kind: isTypeExtensionByDirective ? definition.kind : extensionKindToDefinitionKind[definition.kind],
         name: definition.name
       }
+      if (isTypeExtensionByDirective) {
+        definition.kind = definitionKindToExtensionKind[definition.kind]
+      }
       extensions.push(isGateway ? removeExternalFields(definition) : definition)
     } else if (definition.kind === Kind.DIRECTIVE_DEFINITION) {
       directiveDefinitions.push(definition)
diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js
index 7ac3d9a..0c7b419 100644
--- a/lib/gateway/service-map.js
+++ b/lib/gateway/service-map.js
@@ -10,8 +10,8 @@ const {
 const { buildRequest, sendRequest } = require('./request')
 const SubscriptionClient = require('../subscription-client')
 
-function hasExternalDirective (field) {
-  return field.directives.some(directive => directive.name.value === 'external')
+function hasDirective (directiveName, node) {
+  return node.directives.some(directive => directive.name.value === directiveName)
 }
 
 function createFieldSet (definition, filterFn = () => false) {
@@ -35,13 +35,14 @@ function createTypeMap (schemaDefinition) {
   const extensionTypeMap = {}
 
   for (const definition of parsedSchema.definitions) {
+    const isTypeExtensionByDirective = hasDirective('extends', definition)
     /* istanbul ignore else we are only interested in type definition and type extension scenarios */
-    if (isTypeDefinitionNode(definition)) {
+    if (isTypeDefinitionNode(definition) && !isTypeExtensionByDirective) {
       typeMap[definition.name.value] = createFieldSet(definition)
       types.add(definition.name.value)
-    } else if (isTypeExtensionNode(definition)) {
+    } else if (isTypeExtensionNode(definition) || isTypeExtensionByDirective) {
       typeMap[definition.name.value] = createFieldSet(definition)
-      extensionTypeMap[definition.name.value] = createFieldSet(definition, hasExternalDirective)
+      extensionTypeMap[definition.name.value] = createFieldSet(definition, hasDirective.bind(null, 'external'))
     }
   }
 


Test patch:
diff --git a/tap-snapshots/test-federation.js-TAP.test.js b/tap-snapshots/test-federation.js-TAP.test.js
index 0c280d4..f14795b 100644
--- a/tap-snapshots/test-federation.js-TAP.test.js
+++ b/tap-snapshots/test-federation.js-TAP.test.js
@@ -14,6 +14,8 @@ directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
 
 directive @key(fields: _FieldSet!) on OBJECT | INTERFACE
 
+directive @extends on OBJECT | INTERFACE
+
 directive @customdir on FIELD_DEFINITION
 
 scalar _Any
diff --git a/test/gateway/extends-directive.js b/test/gateway/extends-directive.js
new file mode 100644
index 0000000..f162789
--- /dev/null
+++ b/test/gateway/extends-directive.js
@@ -0,0 +1,161 @@
+'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'
+  }
+}
+
+test('gateway handles @extends directive correctly', async (t) => {
+  const userServicePort = await createService(t, `
+    type Query @extends {
+      me: User
+    }
+
+    type User @key(fields: "id") {
+      id: ID!
+      name: String!
+    }
+  `, {
+    Query: {
+      me: (root, args, context, info) => {
+        return users.u1
+      }
+    },
+    User: {
+      __resolveReference: (user, args, context, info) => {
+        return users[user.id]
+      }
+    }
+  })
+
+  const postServicePort = await createService(t, `
+    type Post @key(fields: "pid") {
+      pid: ID!
+      title: String
+      content: String
+      author: User @requires(fields: "title")
+    }
+
+    type Query @extends {
+      topPosts(count: Int): [Post]
+    }
+
+    type User @key(fields: "id") @extends {
+      id: ID! @external
+      posts: [Post]
+      numberOfPosts: Int
+    }
+  `, {
+    Post: {
+      __resolveReference: (post, args, context, info) => {
+        return posts[post.pid]
+      },
+      author: (post, args, context, info) => {
+        return {
+          __typename: 'User',
+          id: post.authorId
+        }
+      }
+    },
+    User: {
+      posts: (user, args, context, info) => {
+        return Object.values(posts).filter(p => p.authorId === user.id)
+      },
+      numberOfPosts: (user) => {
+        return Object.values(posts).filter(p => p.authorId === user.id).length
+      }
+    },
+    Query: {
+      topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count)
+    }
+  })
+
+  const gateway = Fastify()
+  t.tearDown(() => {
+    gateway.close()
+  })
+  gateway.register(GQL, {
+    gateway: {
+      services: [{
+        name: 'user',
+        url: `http://localhost:${userServicePort}/graphql`
+      }, {
+        name: 'post',
+        url: `http://localhost:${postServicePort}/graphql`
+      }]
+    }
+  })
+
+  await gateway.listen(0)
+
+  const query = `
+    query {
+      me {
+        id
+        name
+        numberOfPosts
+      }
+    }
+  `
+
+  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: {
+        id: 'u1',
+        name: 'John',
+        numberOfPosts: 1
+      }
+    }
+  })
+})
diff --git a/test/gateway/schema.js b/test/gateway/schema.js
index 5332315..a65cbdb 100644
--- a/test/gateway/schema.js
+++ b/test/gateway/schema.js
@@ -108,7 +108,7 @@ test('It builds the gateway schema correctly', async (t) => {
       topPosts(count: Int): [Post]
     }
 
-    extend type User @key(fields: "id") {
+    type User @key(fields: "id") @extends {
       id: ID! @external
       name: String @external
       posts: [Post]