Repository: mcollina/fastify-gql
Pull request #187 (2020-06-16 07:58:29)

Add Support For Automatic Persisted Queries
Adds customisable support for Automatic Persisted Queries, with default settings compatible with `apollo-client`.

#110 already has discussion on this. We required support internally, so it's up to maintainers if support in this way is appropriate. Happy to make tweaks based on feedback if required.

Note this builds on #186.

mcollina: I'm not convinced by this implementation. How do you plan to scale this horizontally? The queries will be stored only on one peer. 
pscarey: Interesting point - I think this is how Apollo Server implemented it, which is why I went down this road, then generalised the way the query is delivered.

The argument in favour would be that the cost of hashing a query the first time an instance sees it will still massively reduce the number of times the full query is sent over the wire.

---

Really, this entire interface or setting might be better off merged into the existing persisted queries solution, in a customisable manner, since there is no firm 'Best Practice', such that there is:

Persisted Query Settings:
-  `isPersistedQuery` check if the given GraphQL request/query is persisted or regular.
- `getHash` get the hash from a GraphQL persisted query request, or falsy if not found.
- `getQueryFromHash` return the query for a given hash, or falsy if not found.
- `getHashForQuery` return the hash for a given query, or falsy if not to be stored.
- `saveQuery` store the query for the given hash.
- `onlyPersisted` whether to allow persisted queries or not.

Example implementations would be:

Current Persisted Queries (Ahead of time):
```
{
  isPersistedQuery: (r) => r.persisted,
  getHash: (r) => r.query,
  getQueryFromHash: (hash) => persistedQueries[hash], // Load from memory
  // getHashForQuery and saveQuery are not required as we do not save new queries
}
```

Apollo Client Compatible Automatic Persisted Queries (Runtime):
```
{
  isPersistedQuery: (r) => r.extensions.persistedQuery.sha256Hash && r.extensions.persistedQuery.version === 1,
  getHash: (r) => r.extensions.persistedQuery.sha256Hash ,
  getQueryFromHash: (hash) => persistedQueries[hash], // Load from memory
  getHashForQuery: (query) => sha256(query),
  saveQuery: (hash, query) => persistedQueries[hash] = query // Store in memory
}
```

Cached Apollo Client Compatible Automatic Persisted Queries (Runtime + Shared Cache):
```
{
  isPersistedQuery: (r) => r.extensions.persistedQuery.sha256Hash && r.extensions.persistedQuery.version === 1,
  getHash: (r) => r.extensions.persistedQuery.sha256Hash ,
  getQueryFromHash: (hash) => redis.get(hash), // Load from redis
  getHashForQuery: (query) => sha256(query),
  saveQuery: (hash, query) => redis.set(hash, query) // Store in redis
}
```




mcollina: My point is that the current interface (ahead of time) cannot be made dynamic without adding support for multiple instances of the server that store the query. Otherwise subsequent requests that hit a different server would not have the cached query.

In the code in this PR the “get” operation is synchronous and it should be made async (and likely moved in a common interface). In your list, the Persisted Query Settings:
-  `isPersistedQuery` check if the given GraphQL request/query is persisted or regular.
- `getHash` get the hash from a GraphQL persisted query request, or falsy if not found.
- `getQueryFromHash` return the query for a given hash, or falsy if not found. __must be async__
- `getHashForQuery` return the hash for a given query, or falsy if not to be stored.
- `saveQuery` store the query for the given hash. __must be async__
- `onlyPersisted` whether to allow persisted queries or not.

---

I’m also skeptical that adding a delay of a round trip to Redis is going to improve latency much in practice. We are trading the cost of sending a few hundred bytes from the client to fetching those from Redis. Given how HTTP/1.1 keepalive and TCP work, sending a few hundreds bytes more should have no impact.
The risk is to have an ahead-of-line blocking on the redis connection which will make thus caching moot.

Notably this makes a lot of sense if our query is massive.
pscarey: I agree with your reasoning that it'd need to be async.

As a general rule, any persisted query requires that the payload reduction savings are greater than the lookup delay. How big the lookup delay is depends on the implementation.

My understanding is that:

Ahead of Time Persisted Queries (AoTPQ):
- Best performance in every use case.
- Hardest to set up, as all queries need to be compiled into hashes ahead of time.

Automated Persisted Queries (APQ):
- Using local memory will be slow for each of their first query, but then give equal performance compared to AoTPQ afterwards.
- The performance of this is basically equivalent to AoTPQ for long lived servers (i.e. not cloud functions).
- This is also the easiest to set up.

Cached APQ:
- Remove that first overhead at the cost of a roundtrip to the cache on every request.
- The performance for this would be worse than APQ for long lived servers, but better than APQ for short lived servers (cloud functions).
- The performance of this is heavily caveated on the payload reduction being sufficiently large to counteract the cost of the lookup time. However, it can reasonably be assumed than an in-datacenter Redis connection is much faster than e.g. a 3G mobile connection. Really this means it boils down to the use case, and the performance of the client.

---

Appreciate the responsiveness here, but I also want to flag that in order for us to migrate to fastify-gql, we need to support APQ as otherwise we'll lose backwards compatibility - obviously that doesn't mean that it's something fastify-gql should support - so if it's a no-go, we can fork.
mcollina: Could you confirm that you are using "sticky sessions" for APQ? How can you guarantee that the same client goes to the same server otherwise? What i do not understand about APQ is if it's actually feasible in production. It seems very likely that a client talking to a different server might not get the cached one.

> I agree with your reasoning that it'd need to be async.

Then we are good, can you update the PR?

---

> Appreciate the responsiveness here, but I also want to flag that in order for us to migrate to fastify-gql, we need to support APQ as otherwise we'll lose backwards compatibility - obviously that doesn't mean that it's something fastify-gql should support - so if it's a no-go, we can fork.

I'm cautious about adding feature, I think this is legit and we should add it in one form or another. 
pscarey: I will do updates to both PR's tomorrow.

> Could you confirm that you are using "sticky sessions" for APQ? How can you guarantee that the same client goes to the same server otherwise? What i do not understand about APQ is if it's actually feasible in production. It seems very likely that a client talking to a different server might not get the cached one.

Our use case is as follows:

**Client:**
- Mobile App running `apollo-client` with approx 20 unique queries.
- Prior versions of the mobile app are supported, but we haven't tracked legacy queries (i.e. they are no longer in our master branch, but are still valid queries due to no breaking changes).
- `apollo-client` attempts to issue a query using the persisted queries hash. Expected API responses are as per [apollo-link-persisted-query](https://github.com/apollographql/apollo-link-persisted-queries#protocol).
- If the client receives the appropriate `PersistedQueryNotFound` response, then it resends the whole query. This causes the APQ cache to be populated for future requests.

**Server:**
- 3-4 replicas of our GraphQL API running in K8s.
- Queries are persisted when they are received in full, in local memory.
- After a short amount of usage, the most common queries will all have been persisted.
- After a little while longer, less frequently called queries are persisted.
- The machine continues its lifetime for a long period after, directly replying to persisted queries.

I hope this (very rough) diagram can covey the idea slightly better:
<img width="957" alt="Screen Shot 2020-06-18 at 10 01 22 pm" src="https://user-images.githubusercontent.com/20896758/85017699-46984980-b1af-11ea-9744-d2d116c5a91d.png">


Sticky sessions would ensure that the cache of the machine returning `PersistedQueryNotFound` is the one populated by the query, but are not strictly necessary, as the queries will still be persisted in a short period of time compared to the lifetime of the machine.


mcollina: Thanks, it makes sense now. Please add a short version of that in the README.
pscarey: I've reworked the API & tidied things up a lot based on the conversation above.

- The proposed API gives defaults which align with the existing options, support for Apollo's APQ, and room for additional customisation.
- Documentation and types are updated.
- All persisted query tests have been moved into a seperate file.
mcollina: Can you rebase this on top of `master`?
pscarey: Have merged the other changes in, diff should be clean.

Thanks.
pscarey: I've:
- Updated the naming to Automatic, and clarified the imports in the README.
- Moved the `PersistedQueryDefaults` into a new file.
- Reverted the change of the naming of the query not found error back to `Unknown query`.
- Added support for `persistedQueries` and `onlyPersisted`, to automatically map to the `persistedQuerySettings` internally.
- Added tests for both using `persistedQuerySettings` and `persistedQueries`/`onlyPersisted`.
pscarey: Minor fixes have been completed. Don't mind the long process - happy to get the prompt feedback, and I appreciate it's a non trivial set of changes.

Should other casing also be updated?
- `Prepared` -> `prepared`
- `PreparedOnly` -> `preparedOnly`
- `Automatic` -> `automatic`


mcollina: @pscarey any updates on this? It would be great to land!
pscarey: @mcollina Sorry about the delay - was away for a few days. Will be updating shortly.
pscarey: All updated. FYI, we've been running this in production for over a week, and have had no issues.
pscarey: Have implemented the feedback, but it looks like some changes from `master` are breaking CI?
mcollina: Yes, we are fixing them today, unfortunately something broke in #147 
mcollina: I have the reverted the offending commit on master.

Patch:
diff --git a/README.md b/README.md
index 6ba2b5b..fd5b150 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Features:
 * Federation support.
 * Gateway implementation, including Subscriptions.
 * Batched query support.
+* Customisable persisted queries.
 
 ## Install
 
@@ -92,51 +93,127 @@ app.get('/', async function (req, reply) {
 app.listen(3000)
 ```
 
-### persistedQueries support
+### Persisted Queries
 
-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.
+GraphQL query strings are often larger than the URLs used in REST requests, sometimes by many kilobytes.
 
-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.
+Depending on the client, this can be a significant overhead for each request, especially given that upload speed is typically the most bandwidth-constrained part of the request lifecycle. Large queries can add significant performance overheads.
 
-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.
+Persisted Queries solve this problem by having the client send a generated ID, instead of the full query string, resulting in a smaller request. The server can use an internal lookup to turn this back into a full query and return the result.
 
+The `persistedQueryProvider` option lets you configure this for Fastify GQL. There are a few default options available, included in `GQL.persistedQueryDefaults`.
+
+#### Prepared
+
+Prepared queries give the best performance in all use cases, at the expense of tooling complexity. Queries must be hashed ahead of time, and a matching set of hashes must be available for both the client and the server. Additionally, version control of query hashes must be considered, e.g. queries used by old clients may need to be kept such that hashes can be calculated at build time. This can be very useful for non-public APIs, but is impractical for public APIs.
+
+Clients can provide a full query string, or set the `persisted` flag to true and provide a hash instead of the query in the request:
 ```js
-'use strict'
+{
+  query: '<hash>',
+  persisted: true
+}
+```
 
-const Fastify = require('fastify')
+A map of hashes to queries must be provided to the server at startup:
+```js
 const GQL = require('fastify-gql')
 
-const app = Fastify()
+app.register(GQL, {
+  ...
+  persistedQueryProvider: GQL.persistedQueryDefaults.prepared({
+    '<hash>':  '{ add(x: 1, y: 1) }'
+  })
+})
+```
 
-const schema = `
-  type Query {
-    add(x: Int, y: Int): Int
-  }
-`
+Alternatively the `peristedQueries` option may be used directly, which will be internally mapped to the `prepared` default:
+```js
+const GQL = require('fastify-gql')
 
-const resolvers = {
-  Query: {
-    add: async (_, { x, y }) => x + y
+app.register(GQL, {
+  ...
+  persistedQueries: {
+    '<hash>':  '{ add(x: 1, y: 1) }'
   }
-}
+})
+```
+
+#### Prepared Only
+
+This offers similar performance and considerations to the `prepared` queries, but only allows persisted queries. This provides additional secuirity benefits, but means that the server **must** know all queries ahead of time or will reject the request.
+
+The API is the same as the `prepared` default.
+
+Alternatively the `peristedQueries` and `onlyPersisted` options may be used directly, which will be internally mapped to the `preparedOnly` default:
+```js
+const GQL = require('fastify-gql')
 
 app.register(GQL, {
-  schema,
-  resolvers,
+  ...
   persistedQueries: {
-    '<unique-hash>': '{ add(x: 1, y: 1) }'
-  }
+    '<hash>': '{ add(x: 1, y: 1) }'
+  },
+  onlyPersisted: true
 })
+```
 
-app.listen(3000)
+#### Automatic
+
+This default is compatible with `apollo-client`, and requires no additional tooling to set up at the cost of some performance. In order for this mode to be effective, you must have long lived server instances (i.e *not* cloud functions). This mode is also appropriate for public APIs where queries are not known ahead of time.
+
+When an unrecognised hash is recieved by the server instance, an error is thrown informing the client that the persisted query has not been seen before. The client then re-sends the full query string. When a full query string is recieved, the server caches the hash of the query string and returns the response. *Note that sticky sessions should be used to ensure optimal performance here by making sure the follow up request is sent to the same server instance.*
+
+The next request for that query (from the same or a different client) will already have been cached and will then be looked up accordingly.
+
+When the server initially starts, no queries will be cached and additional latency will be added to the first requests recieved (due to the client re-sending the full query). However, the most common queries will rapidly be cached by the server. After a warmup (length dependent on the number of queries clients might send and how frequent they are) performance will match that of the `prepared` query option.
+
+Additional documentation on Apollo's automatic persisted queries implementation can be found [here](https://www.apollographql.com/docs/apollo-server/performance/apq/).
+
+Example:
+```js
+const GQL = require('fastify-gql')
+
+app.register(GQL, {
+  ...
+  persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+})
 ```
 
-### Disallowing unknown queries
+An LRU cache is used to prevent DoS attacks on the storage of hashes & queries. The maximum size of this cache (maximum number of cached queries) can be adjusted by passing a value to the constructor, for example:
 
-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`).
+```js
+const GQL = require('fastify-gql')
+
+app.register(GQL, {
+  ...
+  persistedQueryProvider: GQL.persistedQueryDefaults.automatic(5000)
+})
+```
+
+
+#### Custom Persisted Queries
+
+It is also possible to extend or modify these persisted query implementations for custom cases, such as automatic Persisted Queries, but with a shared cache between servers.
+
+This would enable all persisted queries to be shared between all server instances in a cache which is dynamically populated. The lookup time from the cache is an additional overhead for each request, but a higher rate of persisted query matches would be achieved. This may be beneficial, for example, in a public facing API which supports persisted queries and uses cloud functions (short lived server instances). *Note the performance impacts of this need to be considered thoroughly: the latency added to each request must be less than the savings from smaller requests.*
 
-Note: `onlyPersisted` disables all IDEs (graphiql/playground) so typically you'd want to use it in production.
+A example of using this with Redis would be:
 
+```js
+const GQL = require('fastify-gql')
+
+const persistedQueryProvider = {
+  ...GQL.persistedQueryDefaults.automatic(),
+  getQueryFromHash: async (hash) => redis.get(hash),
+  saveQuery: async (hash, query) => redis.set(hash, query),
+}
+
+app.register(GQL, {
+  ...
+  persistedQueryProvider
+})
+```
 
 ### Batched Queries
 
@@ -649,8 +726,16 @@ __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).
+* `persistedQueries`: A hash/query map to resolve the full query text using it's unique hash. Overrides `persistedQueryProvider`.
+* `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). Requires `persistedQueries` to be set, and overrides `persistedQueryProvider`.
+* `persistedQueryProvider`
+  * `isPersistedQuery: (request: object) => boolean`: Return true if a given request matches the desired persisted query format.
+  * `getHash: (request: object) => string`: Return the hash from a given request, or falsy if this request format is not supported.
+  * `getQueryFromHash: async (hash: string) => string`: Return the query for a given hash.
+  * `getHashForQuery?: (query: string) => string`: Return the hash for a given query string. Do not provide if you want to skip saving new queries.
+  * `saveQuery?: async (hash: string, query: string) => void`: Save a query, given its hash.
+  * `notFoundError?: string`: An error message to return when `getQueryFromHash` returns no result. Defaults to `Bad Request`.
+  * `notSupportedError?: string`: An error message to return when a query matches `isPersistedQuery`, but returns no valid hash from `getHash`. Defaults to `Bad Request`.
 * `allowBatchedQueries`: Boolean. Flag to control whether to allow batched queries. When `true`, the server supports recieving an array of queries and returns an array of results.
 
 #### queryDepth example
diff --git a/index.d.ts b/index.d.ts
index 1417b64..622e298 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -41,6 +41,44 @@ declare namespace fastifyGQL {
     schema: GraphQLSchema;
   }
 
+  export interface QueryRequest {
+      operationName?: string
+      query: string
+      variables?: object
+      extensions?: object
+  }
+
+  export interface PeristedQueryProvider {
+      /**
+       *  Return true if a given request matches the desired persisted query format.
+       */
+      isPersistedQuery: (r: QueryRequest) => boolean
+      /**
+       *  Return the hash from a given request, or falsy if this request format is not supported.
+       */
+      getHash: (r: QueryRequest) => string
+      /**
+       *  Return the query for a given hash.
+       */
+      getQueryFromHash: (hash: string) => Promise<string>
+      /**
+       *  Return the hash for a given query string. Do not provide if you want to skip saving new queries.
+       */
+      getHashForQuery?: (query: string) => string
+      /**
+       *  Save a query, given its hash.
+       */
+      saveQuery?: (hash: string, query: string) => Promise<void>
+      /**
+       * An error message to return when getQueryFromHash returns a falsy result. Defaults to 'Bad Request'.
+       */
+      notFoundError?: string
+      /**
+       * An error message to return when a query matches isPersistedQuery, but fasly from getHash. Defaults to 'Bad Request'.
+       */
+      notSupportedError?: string
+  }
+
   export interface Options {
     /**
      * The GraphQL schema. String schema will be parsed
@@ -137,6 +175,19 @@ declare namespace fastifyGQL {
         url: string
       }>
     }
+    /**
+     * Persisted queries, overrides persistedQueryProvider.
+     */
+    persistedQueries?: object
+    /**
+     * Only allow persisted queries. Required persistedQueries, overrides persistedQueryProvider.
+     */
+    onlyPersisted?: boolean
+    /**
+     * Settings for enabling persisted queries.
+     */
+    persistedQueryProvider?: PeristedQueryProvider
+    
     /**
      * Enable support for batched queries (POST requests only).
      * Batched query support allows clients to send an array of queries and
@@ -155,6 +206,15 @@ declare namespace fastifyGQL {
      */
     extensions?: object
   }
+
+  /**
+   * Default options for persisted queries.
+   */
+  export const persistedQueryDefaults: { 
+    prepared: (persistedQueries: object) => PeristedQueryProvider
+    preparedOnly: (persistedQueries: object) => PeristedQueryProvider
+    automatic: (maxSize?: number) => PeristedQueryProvider
+   };
 }
 
 declare module "fastify" {
diff --git a/index.js b/index.js
index 571ed4c..236b0ae 100644
--- a/index.js
+++ b/index.js
@@ -30,6 +30,7 @@ const buildGateway = require('./lib/gateway')
 const mq = require('mqemitter')
 const { PubSub } = require('./lib/subscriber')
 const { ErrorWithProps, FEDERATED_ERROR } = require('./lib/errors')
+const persistedQueryDefaults = require('./lib/persistedQueryDefaults')
 
 const kLoaders = Symbol('fastify-gql.loaders')
 
@@ -60,14 +61,37 @@ const plugin = fp(async function (app, 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) {
+  if (opts.persistedQueries) {
+    if (opts.onlyPersisted) {
+      opts.persistedQueryProvider = persistedQueryDefaults.preparedOnly(opts.persistedQueries)
+
+      // Disable GraphiQL and GraphQL Playground
+      opts.graphiql = false
+      opts.ide = false
+    } else {
+      opts.persistedQueryProvider = persistedQueryDefaults.prepared(opts.persistedQueries)
+    }
+  } else if (opts.onlyPersisted) {
     throw new Error('onlyPersisted is true but there are no persistedQueries')
   }
 
+  if (opts.persistedQueryProvider) {
+    if (opts.persistedQueryProvider.getHash) {
+      if (!opts.persistedQueryProvider.getQueryFromHash) {
+        throw new Error('persistedQueryProvider: getQueryFromHash is required when getHash is provided')
+      }
+    } else {
+      throw new Error('persistedQueryProvider: getHash is required')
+    }
+
+    if (opts.persistedQueryProvider.getHashForQuery) {
+      if (!opts.persistedQueryProvider.saveQuery) {
+        throw new Error('persistedQueryProvider: saveQuery is required when getHashForQuery is provided')
+      }
+    }
+  }
+
   if (typeof minJit !== 'number') {
     throw new Error('the jit option must be a number')
   }
@@ -149,8 +173,7 @@ const plugin = fp(async function (app, opts) {
       prefix: opts.prefix,
       path: opts.path,
       context: opts.context,
-      onlyPersisted: onlyPersisted,
-      persistedQueries: opts.persistedQueries,
+      persistedQueryProvider: opts.persistedQueryProvider,
       allowBatchedQueries: opts.allowBatchedQueries,
       schema: fastifyGraphQl.schema,
       subscriber,
@@ -429,5 +452,6 @@ const plugin = fp(async function (app, opts) {
 })
 
 plugin.ErrorWithProps = ErrorWithProps
+plugin.persistedQueryDefaults = persistedQueryDefaults
 
 module.exports = plugin
diff --git a/lib/persistedQueryDefaults.js b/lib/persistedQueryDefaults.js
new file mode 100644
index 0000000..f8d49bc
--- /dev/null
+++ b/lib/persistedQueryDefaults.js
@@ -0,0 +1,36 @@
+'use strict'
+
+const crypto = require('crypto')
+const LRU = require('tiny-lru')
+
+const persistedQueryDefaults = {
+  prepared: (persistedQueries) => ({
+    isPersistedQuery: (request) => request.persisted,
+    getHash: (request) => request.query,
+    getQueryFromHash: async (hash) => persistedQueries[hash]
+  }),
+  preparedOnly: (persistedQueries) => ({
+    isPersistedQuery: (request) => true,
+    getHash: (request) => request.persisted ? request.query : false, // Only support persisted queries
+    getQueryFromHash: async (hash) => persistedQueries[hash]
+  }),
+  automatic: (maxSize) => {
+    // Initialize a LRU cache in the local scope.
+    // LRU is used to prevent DoS attacks.
+    const cache = LRU(maxSize || 1024)
+    return ({
+      isPersistedQuery: (request) => !request.query && (request.extensions || {}).persistedQuery,
+      getHash: (request) => {
+        const { version, sha256Hash } = request.extensions.persistedQuery
+        return version === 1 ? sha256Hash : false
+      },
+      getQueryFromHash: async (hash) => cache.get(hash),
+      getHashForQuery: (query) => crypto.createHash('sha256').update(query, 'utf8').digest('hex'),
+      saveQuery: async (hash, query) => cache.set(hash, query),
+      notFoundError: 'PersistedQueryNotFound',
+      notSupportedError: 'PersistedQueryNotSupported'
+    })
+  }
+}
+
+module.exports = persistedQueryDefaults
diff --git a/lib/routes.js b/lib/routes.js
index e422846..af8d987 100644
--- a/lib/routes.js
+++ b/lib/routes.js
@@ -82,6 +82,9 @@ const postSchema = (allowBatchedQueries) => ({
       ...requestProperties,
       variables: {
         type: ['object', 'null']
+      },
+      extensions: {
+        type: 'object'
       }
     }
   },
@@ -145,31 +148,93 @@ module.exports = async function (app, opts) {
     verifyClient,
     lruGatewayResolvers,
     entityResolversFactory,
-    onlyPersisted,
-    persistedQueries = {},
+    persistedQueryProvider,
     allowBatchedQueries
   } = opts
 
-  async function execute (body, request, reply) {
-    let { query } = body
-    const { persisted, operationName, variables } = body
-
-    if (persisted || onlyPersisted) {
-      query = persistedQueries[query]
-    }
+  // Load the persisted query settings
+  const {
+    isPersistedQuery,
+    getHash,
+    getQueryFromHash,
+    getHashForQuery,
+    saveQuery,
+    notFoundError,
+    notSupportedError
+  } = persistedQueryProvider || {}
 
+  async function executeQuery (query, variables, operationName, request, reply) {
+    // Validate a query is present
     if (!query) {
       return new BadRequest('Unknown query')
     }
 
+    // Generate the context for this request
     let context = {}
     if (contextFn) {
       context = await contextFn(request, reply)
     }
 
+    // Handle the query, throwing an error if required
     return reply.graphql(query, { pubsub: subscriber, ...context, __currentQuery: query }, variables, operationName)
   }
 
+  function executeRegularQuery (body, request, reply) {
+    const { query, operationName, variables } = body
+    return executeQuery(query, variables, operationName, request, reply)
+  }
+
+  async function executePersistedQuery (body, request, reply) {
+    let { query } = body
+    const { operationName, variables } = body
+
+    // Verify if a query matches the persisted format
+    const persisted = isPersistedQuery(body)
+    if (persisted) {
+      // This is a peristed query, so we use the hash in the request
+      // to load the full query string.
+
+      // Extract the hash from the request
+      const hash = getHash && getHash(body)
+      if (hash) {
+        // Load the query for the provided hash
+        query = await getQueryFromHash(hash)
+
+        if (!query) {
+          // Query has not been found, tell the client
+          throw new BadRequest(notFoundError)
+        }
+
+        // The query has now been set to the full query string
+      } else {
+        // This client should stop sending persisted queries,
+        // as we do not recognise them
+        throw new BadRequest(notSupportedError)
+      }
+    }
+
+    // Execute the query
+    const result = await executeQuery(query, variables, operationName, request, reply)
+
+    // Only save queries which are not yet persisted
+    if (!persisted && query) {
+      // If provided the getHashForQuery, saveQuery settings we save this query
+      const hash = getHashForQuery && getHashForQuery(query)
+      if (hash) {
+        try {
+          await saveQuery(hash, query)
+        } catch (err) {
+          request.log.warn({ err, hash, query }, 'Failed to persist query')
+        }
+      }
+    }
+
+    // Return the result
+    return result
+  }
+
+  const execute = persistedQueryProvider ? executePersistedQuery : executeRegularQuery
+
   const getOptions = {
     url: graphqlPath,
     method: 'GET',


Test patch:
diff --git a/test/persisted.js b/test/persisted.js
new file mode 100644
index 0000000..d8db616
--- /dev/null
+++ b/test/persisted.js
@@ -0,0 +1,966 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const GQL = require('..')
+
+test('persistedQueryProvider errors when getHash is not provided', 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 = {
+    getHash: null
+  }
+
+  t.rejects(async () => {
+    app.register(GQL, {
+      schema,
+      resolvers,
+      persistedQueryProvider
+    })
+
+    await app.ready()
+  })
+})
+
+test('persistedQueryProvider errors when getQueryFromHash is not provided', 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.prepared(),
+    getQueryFromHash: null
+  }
+
+  t.rejects(async () => {
+    app.register(GQL, {
+      schema,
+      resolvers,
+      persistedQueryProvider
+    })
+
+    await app.ready()
+  })
+})
+
+test('persistedQueryProvider errors when saveQuery is not provided', 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(),
+    saveQuery: null
+  }
+
+  t.rejects(async () => {
+    app.register(GQL, {
+      schema,
+      resolvers,
+      persistedQueryProvider
+    })
+
+    await app.ready()
+  })
+})
+
+test('automatic POST new query', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      query: `
+        query AddQuery ($x: Int!, $y: Int!) {
+            add(x: $x, y: $y)
+        }`
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+})
+
+test('automatic POST new query, null result hashing a query is handled', 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,
+    persistedQueryProvider: {
+      ...GQL.persistedQueryDefaults.automatic(),
+      getHashForQuery: () => null
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      query: `
+        query AddQuery ($x: Int!, $y: Int!) {
+            add(x: $x, y: $y)
+        }`
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+})
+
+test('automatic POST new query, error on saveQuery is handled', 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,
+    persistedQueryProvider: {
+      ...GQL.persistedQueryDefaults.automatic(),
+      saveQuery: async (hash, query) => { throw new Error('Failed to save somewhere.') }
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      query: `
+        query AddQuery ($x: Int!, $y: Int!) {
+            add(x: $x, y: $y)
+        }`
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+})
+
+test('automatic POST new persisted query and error', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      extensions: {
+        persistedQuery: {
+          version: 1,
+          sha256Hash: '14b859faf7e656329f24f7fdc7a33a3402dbd8b43f4f57364e15e096143927a9'
+        }
+      }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: null, errors: [{ message: 'PersistedQueryNotFound' }] })
+})
+
+test('automatic POST invalid version persisted query and error', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      extensions: {
+        persistedQuery: {
+          version: 2,
+          magicCode: '123'
+        }
+      }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: null, errors: [{ message: 'PersistedQueryNotSupported' }] })
+})
+
+test('automatic POST invalid extension and error', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: null, errors: [{ message: 'Unknown query' }] })
+})
+
+test('automatic POST invalid extension without persistedQueries and error', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      extensions: {
+        persistedQuery: {}
+      }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: null, errors: [{ message: 'PersistedQueryNotSupported' }] })
+})
+
+test('automatic POST persisted query after priming', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  let res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      query: `
+        query AddQuery ($x: Int!, $y: Int!) {
+            add(x: $x, y: $y)
+        }`
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+
+  res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      extensions: {
+        persistedQuery: {
+          version: 1,
+          sha256Hash: '14b859faf7e656329f24f7fdc7a33a3402dbd8b43f4f57364e15e096143927a9'
+        }
+      }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+})
+
+test('automatic POST persisted query after priming, with extension set in both payloads', 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
+  })
+
+  let res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      query: `
+        query AddQuery ($x: Int!, $y: Int!) {
+            add(x: $x, y: $y)
+        }`,
+      extensions: {
+        persistedQuery: {
+          version: 1,
+          sha256Hash: '14b859faf7e656329f24f7fdc7a33a3402dbd8b43f4f57364e15e096143927a9'
+        }
+      }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+
+  res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {
+      operationName: 'AddQuery',
+      variables: { x: 1, y: 2 },
+      extensions: {
+        persistedQuery: {
+          version: 1,
+          sha256Hash: '14b859faf7e656329f24f7fdc7a33a3402dbd8b43f4f57364e15e096143927a9'
+        }
+      }
+    }
+  })
+
+  t.deepEqual(JSON.parse(res.body), { data: { add: 3 } })
+})
+
+// persistedQueryProvider
+
+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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.prepared({
+      '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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.prepared({
+      '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('preparedOnly 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.preparedOnly({
+      '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('preparedOnly 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.preparedOnly({
+      '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('preparedOnly 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,
+    persistedQueryProvider: GQL.persistedQueryDefaults.preparedOnly({
+      '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)
+})
+
+// persistedQueries
+
+test('persistedQueries 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('persistedQueries 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('onlyPersisted 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) }'
+    },
+    onlyPersisted: true
+  })
+
+  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('onlyPersisted 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,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }'
+    },
+    onlyPersisted: true
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/graphql?query={add(x:2,y:2)}' // custom/unknown query
+  })
+
+  t.is(res.statusCode, 400)
+})
+
+test('onlyPersisted 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,
+    persistedQueries: {
+      '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }'
+    },
+    onlyPersisted: true
+  })
+
+  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/routes.js b/test/routes.js
index 848fe49..14c6b9f 100644
--- a/test/routes.js
+++ b/test/routes.js
@@ -43,6 +43,36 @@ test('POST route', async (t) => {
   })
 })
 
+test('POST route, no query error', 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
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: {}
+  })
+
+  t.equal(res.statusCode, 400)
+  t.deepEqual(JSON.parse(res.body), {
+    errors: [{ message: 'Unknown query' }],
+    data: null
+  })
+})
+
 test('POST route application/graphql', async (t) => {
   const app = Fastify()
   const schema = `
@@ -1627,218 +1657,3 @@ test('if ide is graphiql, serve config.js with the correct endpoint', async (t)
   t.strictEqual(res.headers['content-type'], 'application/javascript')
   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
-  }
-
-  try {
-    app.register(GQL, {
-      schema,
-      resolvers,
-      onlyPersisted: true,
-      persistedQueries: {
-        '248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602': '{ add(x: 1, y: 1) }'
-      }
-    })
-    await app.ready()
-  } catch (error) {
-    t.equal(error.name('GraphQLError'))
-  }
-
-  const res = await app.inject({
-    method: 'POST',
-    url: '/graphql',
-    body: {
-      query: '{ add(x: 2, y: 2) }' // custom/unknown query
-    }
-  })
-
-  t.equal(res.statusCode, 400)
-})