Hive Gateway

Gateway: Persisted Documents / Operations

Persisted documents can be used on your GraphQL server or Gateway to reduce the payload size of your GraphQL requests and secure your GraphQL API by only allowing operations that are known and trusted by your Gateway.

Hive Gateway can use the Hive Schema Registry as a source for persisted documents.

Learn more about setting up app deployments and persisted documents on the Hive Dashboard here.

Configuration

After getting endpoint and token from Hive Registry, you can enable persisted documents in Hive Gateway.

Run the Hive Gateway CLI
hive-gateway supergraph "https://cdn.graphql-hive.com/artifacts/v1/<target_id>" \
  --hive-persisted-documents-endpoint "https://cdn.graphql-hive.com/artifacts/v1/<target_id>" \
  --hive-persisted-documents-token "<cdn_access_token>"
Run the Hive Gateway CLI
docker run --rm --name hive-gateway -p 4000:4000 \
  ghcr.io/graphql-hive/gateway supergraph "https://cdn.graphql-hive.com/artifacts/v1/<target_id>" \
  --hive-persisted-documents-endpoint "https://cdn.graphql-hive.com/artifacts/v1/<target_id>" \
  --hive-persisted-documents-token "<cdn_access_token>"
Run the Hive Gateway CLI
npx hive-gateway supergraph "https://cdn.graphql-hive.com/artifacts/v1/<target_id>" \
  --hive-persisted-documents-endpoint "https://cdn.graphql-hive.com/artifacts/v1/<target_id>" \
  --hive-persisted-documents-token "<cdn_access_token>"

Instead of using the CLI you can also provide the same configuration via the gateway.config.ts file.

gateway.config.ts
import { defineConfig } from "@graphql-hive/gateway";

export const gatewayConfig = defineConfig({
  persistedDocuments: {
    type: "hive",
    // The endpoint of Hive's CDN
    endpoint: [
      "https://cdn.graphql-hive.com/artifacts/v1/<target_id>",
      "https://cdn-mirror.graphql-hive.com/artifacts/v1/<target_id>",
    ],
    // The CDN token provided by Hive Registry
    key: "<cdn access token>",
  },
});

Enabling Arbitrary Documents

After enabling persisted documents on your Hive Gateway, any arbitrary GraphQL documents that don't contain a documentId will be rejected. If you still want to allow executing arbitrary documents, you can set allowArbitraryDocuments to true in the configuration.

gateway.config.ts
import { defineConfig } from '@graphql-hive/gateway'

export const gatewayConfig = defineConfig({
  persistedDocuments: {
    type: 'hive',
    // The endpoint of Hive's CDN
    endpoint: 'https://cdn.graphql-hive.com/artifacts/v1/<target_id>',
    // The CDN token provided by Hive Registry
    key: '<cdn access token>'
    // Allow executing arbitrary documents
    allowArbitraryDocuments: true
  }
})
gateway.config.ts
import { defineConfig } from "@graphql-hive/gateway";

const store = {
  ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38:
    "{__typename}",
};

export const gatewayConfig = defineConfig({
  persistedDocuments: {
    getPersistedOperation(sha256Hash: string) {
      return store[sha256Hash];
    },
  },
});

How to use ?

When using persisted operations, the client sends a hash of the operation instead of the operation itself.

By default, the persisted operations plugin follows the the APQ Specification for SENDING hashes to the server.

However, you can customize the protocol to comply to other implementations e.g. used by Relay persisted queries.

Change this behavior by overriding the getPersistedOperationKey option to support Relay's specification for example.

Execute persisted GraphQL operation
curl -X POST -H 'Content-Type: application/json' http://localhost:4000/graphql \
  -d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}}'

{"data":{"__typename":"Query"}}

As you can see, the persisted operations plugin is able to execute the operation without the need to send the full operation document.

If you now sent a normal GraphQL operation that is not within the store, it will be rejected.

Arbitrary GraphQL operation
curl -X POST -H 'Content-Type: application/json' http://localhost:4000/graphql \
  -d '{"query": "{__typename}"}'

{"errors":[{"message":"PersistedQueryOnly"}]}

Extracting client operations

The recommended way of extracting the persisted operations from your client is to use GraphQL Code Generator.

For people not using the client-preset the is also the standalone graphql-codegen-persisted-query-ids plugin for extracting a map of persisted query ids and their corresponding GraphQL documents from your application/client-code in a JSON file.

Example map extracted by GraphQL Code Generator
{
  "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38": "{__typename}",
  "c7a30a69b731d1af42a4ba02f2fa7a5771b6c44dcafb7c3e5fa4232c012bf5e7": "mutation {__typename}"
}

This map can then be used to persist the GraphQL documents in the server.

gateway.config.ts
import { readFileSync } from "node:fs";
import { defineConfig } from "@graphql-hive/gateway";

const persistedOperations = JSON.parse(
  readFileSync("./persistedOperations.json", "utf-8"),
);

export const gatewayConfig = defineConfig({
  persistedDocuments: {
    getPersistedOperation(sha256Hash: string) {
      return persistedOperations[sha256Hash];
    },
  },
});

Sending the hash from the client

The persisted operations plugin follows the the APQ Specification of Apollo for SENDING hashes to the server.

GraphQL clients such Apollo Client and Urql support that out of the box.

Urql and GraphQL Code Generator

When using the GraphQL Code Generator client preset together with urql, sending the hashes is straight-forward using the @urql/exchange-persisted package.

When you are using the urql graph cache you need to ensure the __typename selections are added to your GraphQL documents selection set.

Please refer to the GraphQL Code Generator client preset documentation for normalized caches for more information.

Urql Client Configuration
import { cacheExchange, createClient } from "@urql/core";
import { persistedExchange } from "@urql/exchange-persisted";

const client = new createClient({
  url: "YOUR_GRAPHQL_ENDPOINT",
  exchanges: [
    cacheExchange,
    persistedExchange({
      enforcePersistedQueries: true,
      enableForMutation: true,
      generateHash: (_, document) =>
        Promise.resolve(document["__meta__"]["hash"]),
    }),
  ],
});

More information on @urql/exchange-persisted on the the urql documentation)

Apollo Client and GraphQL Code Generator

When using the GraphQL Code Generator client preset together with Apollo Client, sending the hashes is straight-forward.

When you are using the urql graph cache you need to ensure the __typename selections are added to your GraphQL documents selection set.

Please refer to the GraphQL Code Generator client preset documentation for normalized caches for more information.

Apollo Client Configuration
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";

const link = createPersistedQueryLink({
  generateHash: (document) => document["__meta__"]["hash"],
});

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: link.concat(new HttpLink({ uri: "/graphql" })),
});

More information on the Apollo Client documentation

Using parsed GraphQL documents as AST

You can reduce the amount of work the server has to do by using the parsed GraphQL documents as AST.

Use parsed GraphQL documents as AST
import { parse } from 'graphql'

const persistedOperations = {
  'my-key': parse(/* GraphQL */ `
    query {
      __typename
    }
  `)
}

{
  getPersistedOperation(key: string) {
    return persistedOperations[key]
  }
}

Skipping validation of persisted operations

If you validate your persisted operations while building your store, we recommend to skip the validation on the server. So this will reduce the work done by the server and the latency of the requests.

Validate persisted operations
{
  //...
  skipDocumentValidation: true;
}

Using AST and skipping validations will reduce the amount of work the server has to do, so the requests will have less latency.

Allowing arbitrary GraphQL operations

Sometimes it is handy to allow non-persisted operations aside from the persisted ones. E.g. you want to allow developers to execute arbitrary GraphQL operations on your production server.

This can be achieved using the allowArbitraryOperations option.

Allow arbitrary GraphQL operations
{
  allowArbitraryOperations: (request) =>
    request.headers.get("x-allow-arbitrary-operations") === "true";
}
Use this option with caution!

Using Relay's Persisted Queries Specification

If you are using Relay's Persisted Queries specification, you can configure the plugin like below;

Relay Persisted Queries example
{
  extractPersistedOperationId(params: GraphqlParams & { doc_id?: unknown }) {
    return typeof params.doc_id === 'string' ? params.doc_id : null
  }
  getPersistedOperation(key: string) {
    return store[key]
  },
},

Advanced persisted operation id Extraction from HTTP Request

You can extract the persisted operation id from the request using the extractPersistedOperationId

Query Parameters Recipe

Extract persisted operation id from query parameters
{
  getPersistedOperation(sha256Hash: string) {
    return store[sha256Hash]
  },
  extractPersistedOperationId(_params, request) {
    const url = new URL(request.url)
    return url.searchParams.get('id')
  }
}

Header Recipe

You can also use the request headers to extract the persisted operation id.

Extract persisted operation id from headers
{
  getPersistedOperation(sha256Hash: string) {
    return store[sha256Hash]
  },
  extractPersistedOperationId(_params, request) {
    return request.headers.get('x-document-id')
  }
}

Path Recipe

You can also the the request path to extract the persisted operation id. This requires you to also customize the GraphQL endpoint. The underlying implementation for the URL matching is powered by the URL Pattern API.

This combination is powerful as it allows you to use the persisted operation id as it can easily be combined with any type of HTTP proxy cache.

gateway.config.ts
import { defineConfig } from "@graphql-hive/gateway";

const store = {
  ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38:
    "{__typename}",
};

export const gatewayConfig = defineConfig({
  graphqlEndpoint: "/graphql/:document_id?",
  persistedDocuments: {
    getPersistedOperation(sha256Hash: string) {
      return store[sha256Hash];
    },
    extractPersistedOperationId(_params, request) {
      return request.url.split("/graphql/").pop() ?? null;
    },
  },
});

Using an external Persisted Operation Store

As a project grows the amount of GraphQL Clients and GraphQL Operations can grow a lot. At some point it might become impractible to store all persisted operations in memory.

In such a scenario you can use an external persisted operation store.

You can return a Promise from the getPersistedOperation function and call any database or external service to retrieve the persisted operation.

For the best performance a mixture of an LRU in-memory store and external persisted operation store is recommended.

Use external persisted operation store
{
  getPersistedOperation(key: string) {
    return fetch(`https://localhost:9999/document/${key}`).then(res => res.json())
  }
}

Using multiple Persisted Operation Stores

You can vary the persisted operations store you read from by switching based on the request.

An example of this may be to use request headers.

Use parsed GraphQL documents as AST
import { parse } from 'graphql'

const persistedOperationsStores = {
  ClientOne: {
    'my-key': parse(/* GraphQL */ `
      query {
        __typename
      }
    `)
  }
}

{
  getPersistedOperation(key: string, request: Request) {
    const store = persistedOperationsStores[request.headers.get('client-name')]
    return (store && store[key]) || null
  }
}

Customize errors

This plugin can throw three different types of errors::

  • PersistedOperationNotFound: The persisted operation cannot be found.
  • PersistedOperationKeyNotFound: The persistence key cannot be extracted from the request.
  • PersistedOperationOnly: An arbitrary operation is rejected because only persisted operations are allowed.

Each error can be customized to change the HTTP status or add a translation message ID, for example.

Customize errors
import { CustomErrorClass } from './custom-error-class'

{
  customErrors: {
    // You can change the error message
    notFound: 'Not Found',
    // Or customize the error with a GraphqlError options object, allowing you to add extensions
    keyNotFound: {
      message: 'Key Not Found',
      extensions: {
        http: {
          status: 404
        }
      }
    },
    // Or customize with a factory function allowing you to use your own error class or format
    persistedQueryOnly: () => {
      return new CustomErrorClass('Only Persisted Operations are allowed')
    }
  }
}