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.
1. Export the schema
Section titled “1. Export the schema”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:
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.
2. Make responses cacheable
Section titled “2. Make responses cacheable”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.
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.
Option B: let GraphPilot read the schema
Section titled “Option B: let GraphPilot read the schema”Turn the header off, and GraphPilot resolves the same @cacheControl hints from schema.graphql
itself:
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
setCacheHintnever leaves your server. If you rely on it, stay with option A. - Add
swrto 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.
3. Verify the request signature
Section titled “3. Verify the request signature”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:
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.
4. Purge when data changes
Section titled “4. Purge when data changes”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.