Repository: mcollina/fastify-gql
Pull request #145 (2020-05-02 10:09:53)

Extended errors
This is a PR for adding feature described in #144 

I'd like for some input on my implementation and naming. 
- Is `FastifyGraphQLError` class for the extended error suitable?
- How should we structure the error parameters? At the moment I'm using Apollo's `ApolloError` format; i.e. `(message: string, code: string, additionalProperties: object)`

yndc: Should we use the current error constructor (with `code`) or just let the developer shape everything inside `extensions` themselves to make it less opinionated?

Then `ErrorWithProps` constructor will only be `new ErrorWithProps(message: string, additionalProperties: object)`

Patch:
diff --git a/README.md b/README.md
index c5e6612..59ac24c 100644
--- a/README.md
+++ b/README.md
@@ -470,6 +470,58 @@ gateway.register(GQL, {
 await gateway.listen(4000)
 ```
 
+### Use errors extension to provide additional information to query errors
+
+GraphQL services may provide an additional entry to errors with the key `extensions` in the result.
+
+```js
+'use strict'
+
+const Fastify = require('fastify')
+const GQL = require('./index')
+const { ErrorWithProps } = GQL
+
+const users = {
+  1: {
+    id: '1',
+    name: 'John'
+  },
+  2: {
+    id: '2',
+    name: 'Jane'
+  }
+}
+
+const app = Fastify()
+const schema = `
+  type Query {
+    findUser(id: String!): User
+  }
+
+  type User {
+    id: ID!
+    name: String
+  }
+`
+
+const resolvers = {
+  Query: {
+    findUser: (_, { id }) => {
+      const user = users[id]
+      if (user) return users[id]
+      else throw new ErrorWithProps('Invalid User ID', "USER_ID_INVALID", { id, timestamp: Math.round(new Date().getTime()/1000) })
+    }
+  }
+}
+
+app.register(GQL, {
+  schema,
+  resolvers
+})
+
+app.listen(3000)
+```
+
 ## API
 
 ### plugin options
diff --git a/index.d.ts b/index.d.ts
index f3a4e22..4112582 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -136,6 +136,21 @@ declare namespace fastifyGQL {
       }>
     }
   }
+
+  /**
+   * Extended errors for adding additional information in error responses
+   */
+  export class ErrorWithProps extends Error {
+    constructor (message: string, code?: string, additionalProperties?: object)
+    /**
+     * Custom error code of this error
+     */
+    code?: string
+    /**
+     * Custom additional properties of this error
+     */
+    additionalProperties?: object
+  } 
 }
 
 declare module "fastify" {
@@ -171,4 +186,5 @@ declare function fastifyGQL<
   fastify: fastify.FastifyInstance<HttpServer, HttpRequest, HttpResponse>,
   opts: Options): void;
 
+
 export = fastifyGQL;
diff --git a/index.js b/index.js
index 69dd77c..11b84bf 100644
--- a/index.js
+++ b/index.js
@@ -10,6 +10,7 @@ const {
   parse,
   buildSchema,
   getOperationAST,
+  GraphQLError,
   GraphQLObjectType,
   GraphQLScalarType,
   GraphQLEnumType,
@@ -26,6 +27,7 @@ const buildFederationSchema = require('./lib/federation')
 const buildGateway = require('./lib/gateway')
 const mq = require('mqemitter')
 const { PubSub } = require('./lib/subscriber')
+const { ErrorWithProps } = require('./lib/errors')
 
 const kLoaders = Symbol('fastify-gql.loaders')
 
@@ -49,7 +51,7 @@ function buildCache (opts) {
   return LRU(1024)
 }
 
-module.exports = fp(async function (app, opts) {
+const plugin = fp(async function (app, opts) {
   const lru = buildCache(opts)
   const lruErrors = buildCache(opts)
   const lruGatewayResolvers = buildCache(opts)
@@ -361,6 +363,15 @@ module.exports = fp(async function (app, opts) {
     )
 
     if (execution.errors) {
+      execution.errors = execution.errors.map(e => {
+        if (Object.prototype.hasOwnProperty.call(e.originalError, 'code') || Object.prototype.hasOwnProperty.call(e.originalError, 'additionalProperties')) {
+          return new GraphQLError(e.originalError.message, e.nodes, e.source, e.positions, e.path, e.originalError, {
+            code: e.originalError.code,
+            ...e.originalError.additionalProperties
+          })
+        }
+        return e
+      })
       const err = new InternalServerError()
       err.errors = execution.errors
       err.data = execution.data
@@ -372,3 +383,7 @@ module.exports = fp(async function (app, opts) {
 }, {
   name: 'fastify-gql'
 })
+
+plugin.ErrorWithProps = ErrorWithProps
+
+module.exports = plugin
diff --git a/lib/errors.js b/lib/errors.js
new file mode 100644
index 0000000..0684d14
--- /dev/null
+++ b/lib/errors.js
@@ -0,0 +1,13 @@
+'use strict'
+
+class ErrorWithProps extends Error {
+  constructor (message, code, additionalProperties) {
+    super(message)
+    this.code = code
+    this.additionalProperties = additionalProperties
+  }
+}
+
+module.exports = {
+  ErrorWithProps: ErrorWithProps
+}
diff --git a/lib/routes.js b/lib/routes.js
index 5660c81..c3e0ce6 100644
--- a/lib/routes.js
+++ b/lib/routes.js
@@ -38,9 +38,9 @@ const responseSchema = {
             extensions: {
               type: 'object',
               properties: {
-                code: { type: 'string' },
-                timestamp: { type: 'string' }
-              }
+                code: { type: 'string' }
+              },
+              additionalProperties: true
             }
           }
         }


Test patch:
diff --git a/test/errors.js b/test/errors.js
new file mode 100644
index 0000000..9358e40
--- /dev/null
+++ b/test/errors.js
@@ -0,0 +1,245 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const GQL = require('..')
+const { ErrorWithProps } = GQL
+
+test('errors - multiple extended errors', async (t) => {
+  const schema = `
+    type Query {
+      errorOne: String
+      errorTwo: String
+      successful: String
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      errorOne () {
+        throw new ErrorWithProps('Error One', 'ERROR_ONE', { additional: 'information one', other: 'data one' })
+      },
+      errorTwo () {
+        throw new ErrorWithProps('Error Two', 'ERROR_TWO', { additional: 'information two', other: 'data two' })
+      },
+      successful () {
+        return 'Runs OK'
+      }
+    }
+  }
+
+  const app = Fastify()
+
+  app.register(GQL, {
+    schema,
+    resolvers
+  })
+
+  await app.ready()
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphql?query={errorOne,errorTwo, successful}'
+  })
+
+  t.equal(res.statusCode, 200)
+  t.deepEqual(JSON.parse(res.payload), {
+    data: {
+      errorOne: null,
+      errorTwo: null,
+      successful: 'Runs OK'
+    },
+    errors: [
+      {
+        message: 'Error One',
+        locations: [
+          {
+            line: 1,
+            column: 2
+          }
+        ],
+        path: ['errorOne'],
+        extensions: {
+          code: 'ERROR_ONE',
+          additional: 'information one',
+          other: 'data one'
+        }
+      },
+      {
+        message: 'Error Two',
+        locations: [
+          {
+            line: 1,
+            column: 11
+          }
+        ],
+        path: ['errorTwo'],
+        extensions: {
+          code: 'ERROR_TWO',
+          additional: 'information two',
+          other: 'data two'
+        }
+      }
+    ]
+  })
+})
+
+test('errors - extended errors with number additionalProperties', async (t) => {
+  const schema = `
+    type Query {
+      willThrow: String
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      willThrow () {
+        throw new ErrorWithProps('Extended Error', 'EXTENDED_ERROR', { floating: 3.14, timestamp: 1324356, reason: 'some reason' })
+      }
+    }
+  }
+
+  const app = Fastify()
+
+  app.register(GQL, {
+    schema,
+    resolvers
+  })
+
+  await app.ready()
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphql?query={willThrow}'
+  })
+
+  t.equal(res.statusCode, 200)
+  t.deepEqual(JSON.parse(res.payload), {
+    data: {
+      willThrow: null
+    },
+    errors: [
+      {
+        message: 'Extended Error',
+        locations: [
+          {
+            line: 1,
+            column: 2
+          }
+        ],
+        path: ['willThrow'],
+        extensions: {
+          code: 'EXTENDED_ERROR',
+          floating: 3.14,
+          timestamp: 1324356,
+          reason: 'some reason'
+        }
+      }
+    ]
+  })
+})
+
+test('errors - extended errors optional parameters', async (t) => {
+  const schema = `
+    type Query {
+      one: String
+      two: String
+      three: String
+      four: String
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      one () {
+        throw new ErrorWithProps('Extended Error')
+      },
+      two () {
+        throw new ErrorWithProps('Extended Error', 'ERROR_TWO')
+      },
+      three () {
+        throw new ErrorWithProps('Extended Error', 'ERROR_THREE', { reason: 'some reason' })
+      },
+      four () {
+        throw new ErrorWithProps('Extended Error', undefined, { reason: 'some reason' })
+      }
+    }
+  }
+
+  const app = Fastify()
+
+  app.register(GQL, {
+    schema,
+    resolvers
+  })
+
+  await app.ready()
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphql?query={one,two,three,four}'
+  })
+
+  t.equal(res.statusCode, 200)
+  t.deepEqual(JSON.parse(res.payload), {
+    data: {
+      one: null,
+      two: null,
+      three: null,
+      four: null
+    },
+    errors: [
+      {
+        message: 'Extended Error',
+        locations: [
+          {
+            line: 1,
+            column: 2
+          }
+        ],
+        path: ['one'],
+        extensions: {}
+      },
+      {
+        message: 'Extended Error',
+        locations: [
+          {
+            line: 1,
+            column: 6
+          }
+        ],
+        path: ['two'],
+        extensions: {
+          code: 'ERROR_TWO'
+        }
+      },
+      {
+        message: 'Extended Error',
+        locations: [
+          {
+            line: 1,
+            column: 10
+          }
+        ],
+        path: ['three'],
+        extensions: {
+          code: 'ERROR_THREE',
+          reason: 'some reason'
+        }
+      },
+      {
+        message: 'Extended Error',
+        locations: [
+          {
+            line: 1,
+            column: 16
+          }
+        ],
+        path: ['four'],
+        extensions: {
+          reason: 'some reason'
+        }
+      }
+    ]
+  })
+})
diff --git a/test/types/index.ts b/test/types/index.ts
index 2288cc2..28e779c 100644
--- a/test/types/index.ts
+++ b/test/types/index.ts
@@ -1,5 +1,5 @@
 import Fastify from 'fastify'
-import GQL from '../..'
+import GQL, { ErrorWithProps } from '../..'
 
 const app = Fastify()
 
@@ -52,18 +52,18 @@ app.register(GQL, {
 
 app.register(async function (app) {
   app.graphql.extendSchema(`
-  type Human {
-    name: String!
-  }
+    type Human {
+      name: String!
+    }
 
-  type Dog {
-    name: String!
-    owner: Human
-  }
+    type Dog {
+      name: String!
+      owner: Human
+    }
 
-  type Query {
-    dogs: [Dog]
-  }
+    type Query {
+      dogs: [Dog]
+    }
   `)
   app.graphql.defineResolvers({
     Query: {
@@ -81,6 +81,19 @@ app.register(async function (app) {
   })
 })
 
+app.register(async function (app) {
+  app.graphql.extendSchema(`
+    type Query {
+      willThrow: String
+    }
+  `)
+  app.graphql.defineResolvers({
+    Query: {
+      willThrow: async () => { throw new ErrorWithProps('Extended Error', 'EXTENDED_ERROR', { reason: 'some reason', other: 32 }) }
+    }
+  })
+})
+
 app.get('/', async function (req, reply) {
   const query = '{ add(x: 2, y: 2) }'
   return reply.graphql(query)