GraphQL Yoga
This page takes a GraphQL Yoga 5 origin 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.
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 Yoga serves, 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 print with
printSchemaWithDirectives from @graphql-tools/utils. It works on the schema createSchema
returns:
import { writeFileSync } from "node:fs"import { printSchemaWithDirectives } from "@graphql-tools/utils"import { schema } from "../src/schema" // the result of createSchema({ typeDefs, resolvers })
writeFileSync("schema.graphql", printSchemaWithDirectives(schema))Run it before every deployment, for example as a step in your CI
workflow. 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”Yoga sends no Cache-Control header of its own, so GraphPilot takes the cache policy from the
@cacheControl hints in your schema. Declare the directive in your type definitions, so the
schema stays valid for Yoga too, and annotate what may be cached:
enum CacheControlScope { PUBLIC PRIVATE}
directive @cacheControl( maxAge: Int scope: CacheControlScope inheritMaxAge: Boolean swr: Int) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
type Query { products: [Product!]! @cacheControl(maxAge: 300, swr: 3600) me: User @cacheControl(maxAge: 60, scope: PRIVATE)}Yoga itself ignores the directive. A hint without scope is PUBLIC, so a field whose answer
depends on the caller needs a scope: PRIVATE for data about one person, a named
scope for data shared within an organization; see partitioning
your cache. A field hint that states no scope keeps its return type’s
scope; see a field hint overrides only the arguments it sets.
If you would rather decide the freshness lifetime (TTL) in code, send a Cache-Control header
from your origin instead. GraphPilot follows it before the schema (see how long an entry lives).
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. A Yoga plugin’s
onRequest hook runs before Yoga reads the body, so it can hash a copy of it:
import type { Plugin } from "graphql-yoga"import { verifyGraphPilotSignature } from "./graphpilot-signature"
export function useGraphPilotSignature(keys: string[]): Plugin { return { async onRequest({ request, url, fetchAPI, endResponse }) { // A clone, so Yoga can still read the original body. const rawBody = Buffer.from(await request.clone().arrayBuffer()) const headers = Object.fromEntries(request.headers) const valid = keys.some((signingKey) => verifyGraphPilotSignature({ method: request.method, pathAndQuery: url.pathname + url.search, rawBody, headers, signingKey, }), ) if (!valid) endResponse(new fetchAPI.Response(null, { status: 401 })) }, }}import { createServer } from "node:http"import { createYoga } from "graphql-yoga"import { schema } from "./schema"import { useGraphPilotSignature } from "./graphpilot-plugin"
const yoga = createYoga({ schema, // During a key rotation, add the pending key here as a second entry. plugins: [useGraphPilotSignature([process.env.GRAPHPILOT_SIGNING_KEY!])],})
createServer(yoga).listen(4000)onRequest runs for every request that reaches your plugin. Yoga’s built-in health check
(/health) answers before it, so probes to that path are unaffected; if your load balancer
probes any other path directly, return early for it before verifying. url has been through
the URL parser, which can re-encode unusual characters; if your clients send such characters in
the query string, take the target from the raw request line instead. 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.