Repository: mercurius-js/mercurius
Pull request #307 (2020-10-08 08:46:21)

feat: support extensions from query for GET requests
This PR allows to use automatic persisted queries using GET requests. 


Patch:
diff --git a/lib/gateway.js b/lib/gateway.js
index 92dc76d..d66aa66 100644
--- a/lib/gateway.js
+++ b/lib/gateway.js
@@ -107,14 +107,25 @@ function defineResolvers (schema, typeToServiceMap, serviceMap, typeFieldsToServ
             } else {
               // check if the field is default field of the type
               if (serviceMap[serviceForType].typeMap[type.name].has(fieldName)) {
-                field.resolve = makeResolver({
-                  service: serviceMap[serviceForFieldType],
-                  createOperation: createEntityReferenceResolverOperation,
-                  transformData: field.astNode.type.kind === Kind.LIST_TYPE
-                    ? response => response.json.data._entities
-                    : response => response.json.data._entities[0],
-                  isReference: true
-                })
+                // Check if field is nullable
+                const isNonNull = field.astNode.type.kind === Kind.NON_NULL_TYPE
+                const leafKind = isNonNull ? field.astNode.type.type.kind : field.astNode.type.kind
+
+                if (leafKind === Kind.LIST_TYPE) {
+                  field.resolve = makeResolver({
+                    service: serviceMap[serviceForFieldType],
+                    createOperation: createEntityReferenceResolverOperation,
+                    transformData: response => response ? response.json.data._entities : (isNonNull ? [] : null),
+                    isReference: true
+                  })
+                } else {
+                  field.resolve = makeResolver({
+                    service: serviceMap[serviceForFieldType],
+                    createOperation: createEntityReferenceResolverOperation,
+                    transformData: response => response.json.data._entities[0],
+                    isReference: true
+                  })
+                }
               } else {
                 field.resolve = makeResolver({
                   service: serviceMap[serviceForFieldType],
diff --git a/lib/routes.js b/lib/routes.js
index 251ccdf..9bfc657 100644
--- a/lib/routes.js
+++ b/lib/routes.js
@@ -63,6 +63,9 @@ const getSchema = {
       ...requestProperties,
       variables: {
         type: 'string' // Stringified JSON
+      },
+      extensions: {
+        type: 'string' // Stringified JSON
       }
     }
   },
@@ -104,6 +107,15 @@ function validationHandler (validationError) {
   }
 }
 
+function tryJSONParse (request, value) {
+  try {
+    return JSON.parse(value)
+  } catch (err) {
+    request.log.info({ err: err })
+    throw new BadRequest(err.message)
+  }
+}
+
 module.exports = async function (app, opts) {
   const errorFormatter = typeof opts.errorFormatter === 'function' ? opts.errorFormatter : defaultErrorFormatter
 
@@ -220,20 +232,13 @@ module.exports = async function (app, opts) {
     handler: async function (request, reply) {
       validationHandler(request.validationError)
 
-      // Parse variables from stringified JSON
-      let { variables } = request.query
-      if (variables) {
-        try {
-          variables = JSON.parse(variables)
-        } catch (err) {
-          request.log.info({ err: err })
-          return reply.send(new BadRequest(err.message))
-        }
-      }
+      const { variables, extensions } = request.query
 
       return execute({
         ...request.query,
-        variables
+        // Parse variables and extensions from stringified JSON
+        variables: variables && tryJSONParse(request, variables),
+        extensions: extensions && tryJSONParse(request, extensions)
       }, request, reply)
     }
   }


Test patch:
diff --git a/test/gateway/schema.js b/test/gateway/schema.js
index 3a70898..1bf98d0 100644
--- a/test/gateway/schema.js
+++ b/test/gateway/schema.js
@@ -164,8 +164,6 @@ test('It builds the gateway schema correctly', async (t) => {
     }
   })
 
-  await gateway.listen(0)
-
   const query = `
   query MainQuery(
     $size: AvatarSize
@@ -361,8 +359,6 @@ test('It support variable inside nested arguments', async (t) => {
     }
   })
 
-  await gateway.listen(0)
-
   const query = `
   query MainQuery(
     $userId: ID!
@@ -495,8 +491,6 @@ test('Should not throw on nullable reference', async (t) => {
     }
   })
 
-  await gateway.listen(0)
-
   const query = `
   {
     topPosts{
@@ -602,8 +596,6 @@ test('Should handle InlineFragment', async (t) => {
     }
   })
 
-  await gateway.listen(0)
-
   const query = `
   {
     products{
@@ -737,8 +729,6 @@ test('Should support array references with _entities query', async (t) => {
     }
   })
 
-  await gateway.listen(0)
-
   const query = `
   {
     topPosts{
@@ -829,8 +819,6 @@ test('Should support multiple `extends` of the same type in the service SDL', as
     }
   })
 
-  await gateway.listen(0)
-
   const res = await gateway.inject({
     method: 'POST',
     headers: {
@@ -865,3 +853,277 @@ test('Should support multiple `extends` of the same type in the service SDL', as
     }
   })
 })
+
+test('Should support array references with _entities query and empty response', async (t) => {
+  const topPosts = [
+    {
+      id: 1,
+      title: 'test',
+      content: 'test',
+      authorIds: []
+    },
+    {
+      id: 2,
+      title: 'test2',
+      content: 'test2',
+      authorIds: []
+    }
+  ]
+
+  const users = [
+    {
+      id: 1,
+      name: 'toto'
+    },
+    {
+      id: 2,
+      name: 'titi'
+    },
+    {
+      id: 3,
+      name: 'tata'
+    }
+  ]
+
+  const postServicePort = await createService(t, `
+    extend type Query {
+      topPosts: [Post]
+    }
+
+    type Post @key(fields: "id") {
+      id: ID!
+      title: String
+      content: String
+      authors: [User]!
+    }
+
+    extend type User @key(fields: "id") {
+      id: ID! @external
+    }
+  `, {
+    Post: {
+      authors: async (root) => {
+        if (root.authorIds) {
+          return root.authorIds.map(id => ({ __typename: 'User', id }))
+        }
+      }
+    },
+    Query: {
+      topPosts: async () => {
+        return topPosts
+      }
+    }
+  })
+
+  const userServicePort = await createService(t, `
+    type User @key(fields: "id") {
+      id: ID!
+      name: String
+    }
+  `, {
+    User: {
+      __resolveReference: async (reference) => {
+        if (reference.id) {
+          return users.find(u => u.id === parseInt(reference.id))
+        }
+      }
+    }
+  })
+
+  const gateway = Fastify()
+  t.tearDown(() => {
+    gateway.close()
+  })
+  gateway.register(GQL, {
+    gateway: {
+      services: [
+        {
+          name: 'post',
+          url: `http://localhost:${postServicePort}/graphql`
+        },
+        {
+          name: 'user',
+          url: `http://localhost:${userServicePort}/graphql`
+        }
+      ]
+    }
+  })
+
+  const query = `
+  {
+    topPosts{
+      id
+      title
+      content
+      authors {
+        id
+        name
+      }
+    }
+  }`
+
+  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: {
+      topPosts: [
+        {
+          id: 1,
+          title: 'test',
+          content: 'test',
+          authors: []
+        },
+        {
+          id: 2,
+          title: 'test2',
+          content: 'test2',
+          authors: []
+        }
+      ]
+    }
+  })
+})
+
+test('Should support array references with _entities query and empty response and nullable field', async (t) => {
+  const topPosts = [
+    {
+      id: 1,
+      title: 'test',
+      content: 'test',
+      authorIds: []
+    },
+    {
+      id: 2,
+      title: 'test2',
+      content: 'test2',
+      authorIds: []
+    }
+  ]
+
+  const users = [
+    {
+      id: 1,
+      name: 'toto'
+    },
+    {
+      id: 2,
+      name: 'titi'
+    },
+    {
+      id: 3,
+      name: 'tata'
+    }
+  ]
+
+  const postServicePort = await createService(t, `
+    extend type Query {
+      topPosts: [Post]
+    }
+
+    type Post @key(fields: "id") {
+      id: ID!
+      title: String
+      content: String
+      authors: [User]
+    }
+
+    extend type User @key(fields: "id") {
+      id: ID! @external
+    }
+  `, {
+    Post: {
+      authors: async (root) => {
+        if (root.authorIds) {
+          return root.authorIds.map(id => ({ __typename: 'User', id }))
+        }
+      }
+    },
+    Query: {
+      topPosts: async () => {
+        return topPosts
+      }
+    }
+  })
+
+  const userServicePort = await createService(t, `
+    type User @key(fields: "id") {
+      id: ID!
+      name: String
+    }
+  `, {
+    User: {
+      __resolveReference: async (reference) => {
+        if (reference.id) {
+          return users.find(u => u.id === parseInt(reference.id))
+        }
+      }
+    }
+  })
+
+  const gateway = Fastify()
+  t.tearDown(() => {
+    gateway.close()
+  })
+  gateway.register(GQL, {
+    gateway: {
+      services: [
+        {
+          name: 'post',
+          url: `http://localhost:${postServicePort}/graphql`
+        },
+        {
+          name: 'user',
+          url: `http://localhost:${userServicePort}/graphql`
+        }
+      ]
+    }
+  })
+
+  const query = `
+  {
+    topPosts{
+      id
+      title
+      content
+      authors {
+        id
+        name
+      }
+    }
+  }`
+
+  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: {
+      topPosts: [
+        {
+          id: 1,
+          title: 'test',
+          content: 'test',
+          authors: null
+        },
+        {
+          id: 2,
+          title: 'test2',
+          content: 'test2',
+          authors: null
+        }
+      ]
+    }
+  })
+})
diff --git a/test/routes.js b/test/routes.js
index 6263030..9c325b5 100644
--- a/test/routes.js
+++ b/test/routes.js
@@ -283,6 +283,70 @@ test('GET route with mistyped variables', async (t) => {
   t.is(res.statusCode, 400)
 })
 
+test('GET route with extensions', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  const persistedQueryProvider = GQL.persistedQueryDefaults.automatic()
+
+  app.register(GQL, {
+    schema,
+    resolvers,
+    persistedQueryProvider
+  })
+
+  const query = 'query ($x: Int!, $y: Int!) { add(x: $x, y: $y) }'
+  const sha256Hash = persistedQueryProvider.getHashForQuery(query)
+  persistedQueryProvider.saveQuery(sha256Hash, query)
+
+  const res = await app.inject({
+    method: 'GET',
+    url: `/graphql?extensions=${JSON.stringify({ persistedQuery: { version: 1, sha256Hash } })}&variables=${JSON.stringify({ x: 2, y: 2 })}`
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+test('GET route with bad JSON extensions', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  const persistedQueryProvider = GQL.persistedQueryDefaults.automatic()
+
+  app.register(GQL, {
+    schema,
+    resolvers,
+    persistedQueryProvider
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphql?extensions=notajson'
+  })
+
+  t.is(res.statusCode, 400)
+})
+
 test('POST route variables', async (t) => {
   const app = Fastify()
   const schema = `