Repository: mercurius-js/mercurius
Pull request #700 (2022-01-06 05:41:03)

add ErrorWithProps handler for mercurius hook
Somehow related to this https://github.com/mercurius-js/mercurius/issues/699

jonnydgreen: Can you add some unit tests please?
sooxt98: @jonnydgreen done

Patch:
diff --git a/docs/api/options.md b/docs/api/options.md
index 7a2e93a..b230229 100644
--- a/docs/api/options.md
+++ b/docs/api/options.md
@@ -557,3 +557,15 @@ Allows the status code of the response to be set, and a GraphQL response for the
 By default uses the `defaultErrorFormatter`, but it can be overridden in the [mercurius options](/docs/api/options.md#plugin-options) changing the errorFormatter parameter.
 
 **Important**: *using the default formatter, when the error has a data property the response status code will be always 200*
+
+While using custom error formatter, you can access the status code provided by the ErrorWithProps object via 
+`originalError` property. Please keep in mind though, that `originalError` is a non-enumerable property, meaning it won't
+get serialized and/or logged as a whole.
+
+```javascript
+app.register(mercurius, {
+    schema,
+    resolvers,
+    errorFormatter: (result) => ({ statusCode: result.errors[0].originalError.statusCode, response: result })
+})
+```
diff --git a/docs/integrations/type-graphql.md b/docs/integrations/type-graphql.md
index c50c5ca..5fa1978 100644
--- a/docs/integrations/type-graphql.md
+++ b/docs/integrations/type-graphql.md
@@ -47,7 +47,7 @@ export class RecipeResolver {
   async recipe(@Arg("title") title: string): Promise<Omit<Recipe, 'specification'> | undefined> {
     return {
       description: "Desc 1",
-      title: "Recipe 1",
+      title: title,
       ratings: [0, 3, 1],
       creationDate: new Date("2018-04-11"),
     };
@@ -60,8 +60,8 @@ This can be linked to the Mercurius plugin:
 ```ts
 // index.ts
 import "reflect-metadata";
-import fastify from "fastify";
-import mercurius from "mercurius";
+import fastify, {FastifyRegisterOptions} from "fastify";
+import mercurius, {MercuriusOptions} from "mercurius";
 import { buildSchema } from 'type-graphql'
 
 import { RecipeResolver } from "./recipe";
@@ -74,13 +74,21 @@ async function main() {
 
   const app = fastify();
 
-  app.register(mercurius, {
+  const opts: FastifyRegisterOptions<MercuriusOptions> = {
     schema,
-    graphiql: true,
-  });
+    graphiql: true
+  }
+  app.register(mercurius, opts);
 
   app.get("/", async (req, reply) => {
-    const query = "{ add(x: 2, y: 2) }";
+    const query = `{ 
+      recipe(title: "Recipe 1") {
+        title
+        description
+        ratings
+        creationDate
+      }
+    }`;
     return reply.graphql(query);
   });
 
@@ -95,3 +103,69 @@ If you run this, you will get a GraphQL API based on your code:
 ```bash
 ts-node index.ts
 ```
+
+## Class validators
+
+One of the features of `type-graphql` is ability to add validation rules using decorators. Let's say we want to add
+a mutation with some simple validation rules for its input. First we need to define the class for the input:
+
+```ts
+@InputType()
+export class RecipeInput {
+    @Field()
+    @MaxLength(30)
+    title: string;
+
+    @Field({ nullable: true })
+    @Length(30, 255)
+    description?: string;
+}
+```
+
+Then add a method in the `RecipeResolver` that would serve as a mutation implementation:
+
+```ts
+@Mutation(returns => Recipe)
+async addRecipe(@Arg("input") recipeInput: RecipeInput): Promise<Recipe> {
+    const recipe = new Recipe();
+    recipe.description = recipeInput.description;
+    recipe.title = recipeInput.title;
+    recipe.creationDate = new Date();
+    return recipe;
+}
+```
+
+Now, here we can run into a problem. Getting the details of validation errors can get confusing. Normally, the default 
+error formatter of `mercurius` will handle the error, log them and carry over the details to the response of API call.
+The problem is that validation errors coming from `type-graphql` are stored in `originalError` field (in contrast to
+the `extensions` field, which was designed to be carrying such data) of `GraphQLError` object, which is a non-enumerable
+property (meaning it won't get serialized/logged). 
+
+An easy workaround would be to copy the validation details from `originalError` to `extensions` field using custom error
+formatter. The problem is that in GraphQLError's constructor method, if the extensions are empty initially, then this
+field is being marked as a non-enumerable as well. To work this problem around you could do something like this:
+
+```ts
+const app = fastify({ logger: { level: 'info' } });
+const opts: FastifyRegisterOptions<MercuriusOptions> = {
+    schema,
+    graphiql: true,
+    errorFormatter: (executionResult, context) => {
+        const log = context.reply ? context.reply.log : context.app.log;
+        const errors = executionResult.errors.map((error) => {
+            error.extensions.exception = error.originalError;
+            Object.defineProperty(error, 'extensions', {enumerable: true});
+            return error;
+        });
+        log.info({ err: executionResult.errors }, 'Argument Validation Error');
+        return {
+            statusCode: 201,
+            response: {
+                data: executionResult.data,
+                errors
+            }
+        }
+    }
+}
+app.register(mercurius, opts);
+```
\ No newline at end of file
diff --git a/lib/errors.js b/lib/errors.js
index 4888ccc..b7888a0 100644
--- a/lib/errors.js
+++ b/lib/errors.js
@@ -43,7 +43,7 @@ function toGraphQLError (err) {
 }
 
 function defaultErrorFormatter (err, ctx) {
-  let errors = [{ message: err.message }]
+  let errors = [err instanceof ErrorWithProps ? formatError(toGraphQLError(err)) : { message: err.message }]
   // There is always app if there is a context
   const log = ctx.reply ? ctx.reply.log : ctx.app.log
 


Test patch:
diff --git a/test/hooks.js b/test/hooks.js
index 7b674d1..f71e1b7 100644
--- a/test/hooks.js
+++ b/test/hooks.js
@@ -7,6 +7,7 @@ const { mapSchema } = require('@graphql-tools/utils')
 const { parse, buildSchema, GraphQLSchema } = require('graphql')
 const { promisify } = require('util')
 const GQL = require('..')
+const { ErrorWithProps } = GQL
 
 const immediate = promisify(setImmediate)
 
@@ -281,6 +282,53 @@ test('preParsing hooks should handle errors', async t => {
   })
 })
 
+test('preParsing hooks should handle ErrorWithProps', async t => {
+  t.plan(4)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.equal(source, query)
+    t.type(context, 'object')
+    throw new ErrorWithProps('a preParsing error occured', { code: 'USER_ID_INVALID' })
+  })
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.same(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preParsing error occured',
+        extensions: {
+          code: 'USER_ID_INVALID'
+        }
+      }
+    ]
+  })
+})
+
 test('preParsing hooks should be able to put values onto the context', async t => {
   t.plan(8)
   const app = await createTestServer(t)