Repository: mcollina/fastify-gql
Pull request #139 (2020-04-21 10:04:58)

feat: support for persisted queries
First draft for https://github.com/mcollina/fastify-gql/issues/110

- Added support to accept `persistedQueries` as the plugin options. It's a JSON object containing hash -> query mapping which is used by GET queries handler to resolve to full query text.

salmanm: Added option `onlyPersisted`. Also added some tests.
frikille: Shouldn't persisted queries be supported via `POST` as well? It would increase security by rejecting unknown queries.
salmanm: Good point. The initial plan wasn't to make the server reject unknown queries, but now it is. So it'd make sense to do so on post too. Updating.

Patch:
diff --git a/README.md b/README.md
index c5e6612..d5c52a8 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,52 @@ app.get('/', async function (req, reply) {
 app.listen(3000)
 ```
 
+### persistedQueries support
+
+Unlike REST APIs that use a fixed URL to load data, GraphQL queries are often much longer than that, in some cases by many kilobytes. This is actually significant overhead. When paired with the fact that the uplink speed from the client is typically the most bandwidth-constrained part of the chain, large queries can become bottlenecks for client performance.
+
+Persisted Queries solves this problem by sending a generated ID instead of the query text as the request. This smaller signature reduces bandwidth utilization and speeds up client loading times.
+
+See the example below, we're supplying an object containing hash of each query to the server. This object will be used to lookup actual query texts when server recieves a request containing hash of the query.
+
+```js
+'use strict'
+
+const Fastify = require('fastify')
+const GQL = require('fastify-gql')
+
+const app = Fastify()
+
+const schema = `
+  type Query {
+    add(x: Int, y: Int): Int
+  }
+`
+
+const resolvers = {
+  Query: {
+    add: async (_, { x, y }) => x + y
+  }
+}
+
+app.register(GQL, {
+  schema,
+  resolvers,
+  persistedQueries: {
+    '<unique-hash>': '{ add(x: 1, y: 1) }'
+  }
+})
+
+app.listen(3000)
+```
+
+### Disallowing unknown queries
+
+In addition to the `persistedQueries` above, which let's client request for either query text OR a hash (if pre-fed), there's another option available called `onlyPersisted`. This is useful when you want to secure the server by disallowing any unknown requests. It will ensure the server never responds to any query except what it already knows (form the `persistedQueries`).
+
+Note: `onlyPersisted` disables all IDEs (graphiql/playground) so typically you'd want to use it in production.
+
+
 ### Access app context in resolver
 
 ```js
@@ -470,6 +516,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
@@ -486,6 +584,7 @@ __fastify-gql__ supports the following options:
   [GraphiQL](https://www.npmjs.com/package/graphiql) on `/graphiql` if `true` or `'graphiql'`, or
   [GraphQL IDE](https://www.npmjs.com/package/graphql-playground-react) on `/playground` if `'playground'`
   and if `routes` is `true`. Leave empty or `false` to disable.
+  _only applies if `onlyPersisted` option is not `true`_
 * `jit`: Integer. The minimum number of execution a query needs to be
   executed before being jit'ed.
 * `routes`: boolean. Serves the Default: `true`. A graphql endpoint is
@@ -507,6 +606,8 @@ __fastify-gql__ supports the following options:
     * `service.rewriteHeaders`: `Function` A function that gets the original headers as a parameter and returns an object containing values that should be added to the headers
     * `service.wsUrl`: The url of the websocket endpoint
     * `service.wsConnectionParams`: `Function` or `Object`
+* `persistedQueries`: A hash/query map to resolve the full query text using it's unique hash.
+* `onlyPersisted`: Boolean. Flag to control whether to allow graphql queries other than persisted. When `true`, it'll make the server reject any queries that are not present in the `persistedQueries` option above. It will also disable any ide available (playground/graphiql).
 
 #### queryDepth example
 ```
diff --git a/examples/persisted-queries/index.js b/examples/persisted-queries/index.js
new file mode 100644
index 0000000..999ac4d
--- /dev/null
+++ b/examples/persisted-queries/index.js
@@ -0,0 +1,32 @@
+'use strict'
+
+const Fastify = require('fastify')
+const GQL = require('../..')
+const persistedQueries = require('./queries.json')
+
+const app = Fastify()
+
+const schema = `
+  type Query {
+    add(x: Int, y: Int): Int
+  }
+`
+
+const resolvers = {
+  Query: {
+    add: async (_, obj) => {
+      const { x, y } = obj
+      return x + y
+    }
+  }
+}
+
+app.register(GQL, {
+  schema,
+  resolvers,
+  persistedQueries,
+  onlyPersisted: true, // will nullify the effect of the option below (graphiql)
+  graphiql: true
+})
+
+app.listen(3000)
diff --git a/examples/persisted-queries/queries.json b/examples/persisted-queries/queries.json
new file mode 100644
index 0000000..6a27c50
--- /dev/null
+++ b/examples/persisted-queries/queries.json
@@ -0,0 +1,5 @@
+{
+  "248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602": "{ add(x: 1, y: 1) }",
+  "495ccd73abc8436544cfeedd65f24beee660d2c7be2c32536e3fbf911f935ddf": "query Add($x: Int!, $y: Int!) { add(x: $x, y: $y) }",
+  "03ec1635d1a0ea530672bf33f28f3533239a5a7021567840c541c31d5e28c65e": "{ add(x: 3, y: 3) }"
+}
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..c5ba214 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,13 +51,20 @@ 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)
 
   const minJit = opts.jit || 0
   const queryDepthLimit = opts.queryDepth
+  const onlyPersisted = !!opts.onlyPersisted
+  opts.graphiql = onlyPersisted ? false : opts.graphiql
+  opts.ide = onlyPersisted ? false : opts.ide
+
+  if (onlyPersisted && !opts.persistedQueries) {
+    throw new Error('onlyPersisted is true but there are no persistedQueries')
+  }
 
   if (typeof minJit !== 'number') {
     throw new Error('the jit option must be a number')
@@ -136,6 +145,8 @@ module.exports = fp(async function (app, opts) {
       prefix: opts.prefix,
       path: opts.path,
       context: opts.context,
+      onlyPersisted: onlyPersisted,
+      persistedQueries: opts.persistedQueries,
       schema,
       subscriber,
       verifyClient,
@@ -361,6 +372,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 +392,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..bd94aae 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
             }
           }
         }
@@ -93,7 +93,9 @@ module.exports = async function (app, opts) {
     subscriber,
     verifyClient,
     lruGatewayResolvers,
-    entityResolversFactory
+    entityResolversFactory,
+    onlyPersisted,
+    persistedQueries = {}
   } = opts
 
   const getOptions = {
@@ -106,6 +108,9 @@ module.exports = async function (app, opts) {
           query: {
             type: 'string'
           },
+          persisted: {
+            type: 'boolean'
+          },
           operationName: {
             type: 'string'
           },
@@ -122,10 +127,20 @@ module.exports = async function (app, opts) {
 
       let {
         query,
+        persisted,
         variables,
         operationName
       } = request.query
 
+      if (persisted || onlyPersisted) {
+        query = persistedQueries[query]
+      }
+
+      if (!query) {
+        reply.send(new BadRequest('Unknown query'))
+        return
+      }
+
       if (variables) {
         try {
           variables = JSON.parse(variables)
@@ -176,6 +191,10 @@ module.exports = async function (app, opts) {
             type: 'string',
             description: 'the GraphQL query'
           },
+          persisted: {
+            type: 'boolean',
+            description: 'flag to indicate if requesting for a persisted query using it\'s hash'
+          },
           operationName: {
             type: 'string'
           },
@@ -191,12 +210,22 @@ module.exports = async function (app, opts) {
   }, async function (request, reply) {
     validationHandler(request.validationError)
 
+    let { query } = request.body
     const {
-      query,
+      persisted,
       variables,
       operationName
     } = request.body
 
+    if (persisted || onlyPersisted) {
+      query = persistedQueries[query]
+    }
+
+    if (!query) {
+      reply.send(new BadRequest('Unknown query'))
+      return
+    }
+
     let context = {}
     if (contextFn) {
       context = await contextFn(request, reply)
diff --git a/package.json b/package.json
index 1a8f62b..7e9862d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "fastify-gql",
-  "version": "3.3.0",
+  "version": "3.4.0",
   "description": "Fastify GraphQL adapter",
   "main": "index.js",
   "types": "index.d.ts",


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/loaders.js b/test/loaders.js
index c45567f..b175117 100644
--- a/test/loaders.js
+++ b/test/loaders.js
@@ -455,3 +455,13 @@ test('reply is empty, throw error', async (t) => {
     t.equal(error.errors[0].message, 'loaders only work via reply.graphql()')
   }
 })
+
+test('throw when persistedQueries is empty but onlyPersisted is true', async t => {
+  const app = Fastify()
+
+  app.register(GQL, {
+    onlyPersisted: true
+  })
+
+  t.rejects(app.ready(), 'onlyPersisted is true but there are no persistedQueries')
+})
diff --git a/test/routes.js b/test/routes.js
index 91ac00b..2b18823 100644
--- a/test/routes.js
+++ b/test/routes.js
@@ -1410,3 +1410,213 @@ test('if ide is graphiql, serve config.js with the correct endpoint', async (t)
   t.strictEqual(res.statusCode, 200)
   t.matchSnapshot(res.body)
 })
+
+test('GET route with query, variables & persisted', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }',
+      '495ccd73abc8436544cfeedd65f24beee660d2c7be2c32536e3fbf911f935ddf': 'query Add($x: Int!, $y: Int!) { add(x: $x, y: $y) }',
+      '03ec1635d1a0ea530672bf33f28f3533239a5a7021567840c541c31d5e28c65e': '{ add(x: 3, y: 3) }'
+    }
+  })
+
+  const res1 = await app.inject({
+    method: 'GET',
+    url: '/graphql?query=248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602&persisted=true'
+  })
+
+  t.deepEqual(JSON.parse(res1.body), {
+    data: {
+      add: 2
+    }
+  })
+
+  const res2 = await app.inject({
+    method: 'GET',
+    url: '/graphql?query=495ccd73abc8436544cfeedd65f24beee660d2c7be2c32536e3fbf911f935ddf&variables={"x":2,"y":2}&persisted=true'
+  })
+
+  t.deepEqual(JSON.parse(res2.body), {
+    data: {
+      add: 4
+    }
+  })
+
+  const res3 = await app.inject({
+    method: 'GET',
+    url: '/graphql?query=03ec1635d1a0ea530672bf33f28f3533239a5a7021567840c541c31d5e28c65e&persisted=true'
+  })
+
+  t.deepEqual(JSON.parse(res3.body), {
+    data: {
+      add: 6
+    }
+  })
+})
+
+test('POST route with query, variables & persisted', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }',
+      '495ccd73abc8436544cfeedd65f24beee660d2c7be2c32536e3fbf911f935ddf': 'query Add($x: Int!, $y: Int!) { add(x: $x, y: $y) }',
+      '03ec1635d1a0ea530672bf33f28f3533239a5a7021567840c541c31d5e28c65e': '{ add(x: 3, y: 3) }'
+    }
+  })
+
+  const res1 = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      query: '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602',
+      persisted: true
+    }
+  })
+
+  t.deepEqual(JSON.parse(res1.body), {
+    data: {
+      add: 2
+    }
+  })
+
+  const res2 = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      query: '495ccd73abc8436544cfeedd65f24beee660d2c7be2c32536e3fbf911f935ddf',
+      variables: { x: 2, y: 2 },
+      persisted: true
+    }
+  })
+
+  t.deepEqual(JSON.parse(res2.body), {
+    data: {
+      add: 4
+    }
+  })
+
+  const res3 = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      query: '03ec1635d1a0ea530672bf33f28f3533239a5a7021567840c541c31d5e28c65e',
+      persisted: true
+    }
+  })
+
+  t.deepEqual(JSON.parse(res3.body), {
+    data: {
+      add: 6
+    }
+  })
+})
+
+test('if onlyPersisted then nullify graphiql option', async (t) => {
+  const app = Fastify()
+  app.register(GQL, {
+    graphiql: true,
+    onlyPersisted: true,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }'
+    }
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphiql/main.js'
+  })
+  t.strictEqual(res.statusCode, 404)
+
+  const res2 = await app.inject({
+    method: 'GET',
+    url: '/graphiql/sw.js'
+  })
+  t.strictEqual(res2.statusCode, 404)
+})
+
+test('if onlyPersisted then reject unknown queries with 400 for GET route', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers,
+    graphiql: true,
+    onlyPersisted: true,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }'
+    }
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphql?query={add(x:2,y:2)}' // custom/unknown query
+  })
+
+  t.is(res.statusCode, 400)
+})
+
+test('if onlyPersisted then reject unknown queries with 400 for POST route', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers,
+    onlyPersisted: true,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }'
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      query: '{ add(x: 2, y: 2) }' // custom/unknown query
+    }
+  })
+
+  t.equal(res.statusCode, 400)
+})
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)