Skip to content

Apollo Server

This page takes an Apollo Server 4 or 5 origin running on Express through four steps: export the schema GraphPilot deploys, make responses cacheable, check that requests really came through GraphPilot, and purge when data changes. Each step stands on its own, so you can stop after the first two and add the rest later.

The examples use the Express integration, @as-integrations/express5 (or @as-integrations/express4 if you are still on Express 4; the API is the same). On Apollo Server 4 you may still import expressMiddleware from @apollo/server/express4.

Mount Apollo Server on the path in request.graphql_paths (/graphql by default) and nowhere else. GraphPilot reads a request as GraphQL on that path, on paths below it and in any letter case, the way Express routes app.use("/graphql", ...), and forwards every other path as plain HTTP. startStandaloneServer answers GraphQL on every path. GraphPilot refuses an operation sent to another path with 404 and NOT_A_GRAPHQL_PATH, but it does not recognize an upload or a subscription there, so use an integration that mounts Apollo Server on one path, such as the Express one below.

A deployment is built from a schema.graphql file next to your gpilot.toml (see deployments). It has to be the schema your server runs, with its directives left in: @cacheControl and @surrogateKey are read from that file, not from your server.

printSchema from the graphql package drops every directive you applied, so use printSchemaWithDirectives from @graphql-tools/utils instead:

scripts/export-schema.ts
import { writeFileSync } from "node:fs"
import { makeExecutableSchema } from "@graphql-tools/schema"
import { printSchemaWithDirectives } from "@graphql-tools/utils"
import { typeDefs } from "../src/schema"
// Resolvers are not needed to print the schema.
const schema = makeExecutableSchema({ typeDefs })
writeFileSync("schema.graphql", printSchemaWithDirectives(schema))

Run it before every deployment, for example as a step in your CI workflow. If your type definitions already live in .graphql files, concatenating them into schema.graphql works just as well.

Deploy the new schema to GraphPilot before you release the server that adds fields to it. A query selecting a field the deployed schema does not declare is answered but never cached (CACHE_SKIPPED_UNRESOLVED_SELECTION); see deploy the schema before the origin.

Apollo Server installs its cache-control plugin in every server by default. The plugin computes a policy from your @cacheControl hints (and from hints your resolvers set at runtime) and sends it as a response header: Cache-Control: max-age=N, public or max-age=N, private when the response is cacheable, and Cache-Control: no-store when it is not.

GraphPilot follows an origin Cache-Control header before it looks at the schema (see how long an entry lives). So you choose who decides the freshness lifetime (TTL): Apollo Server, or GraphPilot reading your schema.

Option A: keep Apollo Server’s header (the default)

Section titled “Option A: keep Apollo Server’s header (the default)”

Change nothing. Declare the directive as Apollo Server asks you to, annotate your types and fields, and GraphPilot caches each response for as long as the header says. A private header caches one entry per caller once your service defines a private partition key; without one, private responses are not cached. no-store is never cached.

schema.graphql
enum CacheControlScope {
PUBLIC
PRIVATE
}
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
type Query {
products: [Product!]! @cacheControl(maxAge: 300)
me: User @cacheControl(maxAge: 60, scope: PRIVATE)
}

This is the right choice if your resolvers set hints at runtime (info.cacheControl.setCacheHint). Those hints only exist inside Apollo Server, and the header is the only way they reach GraphPilot.

Turn the header off, and GraphPilot resolves the same @cacheControl hints from schema.graphql itself:

src/server.ts
import { ApolloServer } from "@apollo/server"
import { ApolloServerPluginCacheControl } from "@apollo/server/plugin/cacheControl"
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginCacheControl({ calculateHttpHeaders: false })],
})

What you gain: a stale window per field with swr, and named scopes for data shared within an organization but not beyond it. What changes compared with Apollo Server:

  • Runtime hints are invisible. Anything a resolver sets with setCacheHint never leaves your server. If you rely on it, stay with option A.
  • Add swr to your directive definition if you use it (swr: Int), so the schema stays valid for Apollo Server as well.

Do not use calculateHttpHeaders: "if-cacheable" either. It leaves the header off exactly the responses Apollo Server found uncacheable, and GraphPilot then falls back to the schema for them, which can cache a response Apollo Server would not have.

GraphPilot signs every request it forwards with your service’s signing key (see request signing). Checking that signature lets your origin refuse anything that did not come through GraphPilot.

You need the signing key. The portal and gpilot service create each show it once, when the service is created, and neither shows it again. If you did not keep it, rotate it in the portal to get a new one. Store it as GRAPHPILOT_SIGNING_KEY.

Copy the Node.js verifyGraphPilotSignature function from verify the signature on your origin into src/graphpilot-signature.ts. The signature covers the raw request body, so capture the bytes while express.json() parses them, and verify before Apollo Server runs:

src/server.ts
import type { IncomingMessage } from "node:http"
import cors from "cors"
import express from "express"
import { expressMiddleware } from "@as-integrations/express5"
import { verifyGraphPilotSignature } from "./graphpilot-signature"
type WithRawBody = IncomingMessage & { rawBody?: Buffer }
// During a key rotation, add the pending key here as a second entry.
const SIGNING_KEYS = [process.env.GRAPHPILOT_SIGNING_KEY!]
const app = express()
await server.start() // your ApolloServer instance
app.use(
"/graphql",
cors(),
express.json({ verify: (req, _res, buf) => { (req as WithRawBody).rawBody = buf } }),
(req, res, next) => {
const valid = SIGNING_KEYS.some((signingKey) =>
verifyGraphPilotSignature({
method: req.method,
pathAndQuery: req.originalUrl,
rawBody: (req as WithRawBody).rawBody ?? Buffer.alloc(0),
headers: req.headers,
signingKey,
}),
)
if (valid) next()
else res.status(401).end()
},
expressMiddleware(server),
)

A request with no body (a GET) hashes the empty string, which is what Buffer.alloc(0) gives you. express.json() only reads application/json bodies; if your clients send another content type, widen it with type, or a signed request with such a body fails verification. req.originalUrl is the path and query string as GraphPilot sent them; if a load balancer in front of your app rewrites the path, verify against the path GraphPilot used. The known test vector checks your setup byte for byte.

A mutation that returns the changed object purges it for you. GraphPilot tags the mutation response with the keys of the objects in it and purges every cached response that contained them (see automatic invalidation). So let your mutations return the object they changed, not a Boolean:

type Mutation {
updateProduct(id: ID!, input: ProductInput!): Product! # purges Product:id:<id>
deleteProduct(id: ID!): Boolean! # purges nothing
}

The client does not have to select id for this to work: GraphPilot adds the key fields to the query and removes them again before the response reaches the client.

When data changes outside GraphQL (an admin tool, an import job, a queue consumer), purge the keys from that code with gpilot purge or the purgeBySurrogateKey mutation, for example Product:id:<id>. The API key needs the cache:purge permission. See purging through the API and CLI for the call, its payload and where the service id comes from.