diff --git a/.changeset/long-tips-try.md b/.changeset/long-tips-try.md new file mode 100644 index 0000000000..665402e7d8 --- /dev/null +++ b/.changeset/long-tips-try.md @@ -0,0 +1,6 @@ +--- +'@graphql-yoga/plugin-persisted-operations': minor +--- + +Inject request into `extractPersistedOperationId` function for allowing to extract the ID based on +request header, query parameters or request path. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 40332b99c4..5fa5e41aab 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,7 +23,7 @@ jobs: if: ${{ github.event.pull_request.title != 'Upcoming Release Changes' }} with: npmTag: rc - restoreDeletedChangesets: true + restoreDeletedChangesets: false buildScript: build nodeVersion: 20 packageManager: pnpm diff --git a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts index 213e9d8f4b..f11d112c88 100644 --- a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts +++ b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts @@ -391,4 +391,82 @@ describe('Persisted Operations', () => { expect(body.errors).toBeUndefined(); expect(body.data.__typename).toBe('Query'); }); + + it('extract key from request query parameter', async () => { + const store = new Map(); + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null; + }, + extractPersistedOperationId(_params, request) { + const url = new URL(request.url); + return url.searchParams.get('id'); + }, + }), + ], + schema, + }); + const persistedOperationKey = 'my-persisted-operation'; + store.set(persistedOperationKey, '{__typename}'); + const response = await yoga.fetch(`http://yoga/graphql?id=${persistedOperationKey}`); + + const body = await response.json(); + expect(body.errors).toBeUndefined(); + expect(body.data.__typename).toBe('Query'); + }); + + it('extract key from request header', async () => { + const store = new Map(); + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null; + }, + extractPersistedOperationId(_params, request) { + return request.headers.get('x-document-id'); + }, + }), + ], + schema, + }); + const persistedOperationKey = 'my-persisted-operation'; + store.set(persistedOperationKey, '{__typename}'); + const response = await yoga.fetch(`http://yoga/graphql`, { + headers: { + 'x-document-id': persistedOperationKey, + }, + }); + + const body = await response.json(); + expect(body.errors).toBeUndefined(); + expect(body.data.__typename).toBe('Query'); + }); + + it('extract key from path', async () => { + const store = new Map(); + const yoga = createYoga({ + graphqlEndpoint: '/graphql/:document_id?', + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null; + }, + extractPersistedOperationId(_params, request) { + return request.url.split('/graphql/').pop() ?? null; + }, + }), + ], + schema, + }); + const persistedOperationKey = 'my-persisted-operation'; + store.set(persistedOperationKey, '{__typename}'); + const response = await yoga.fetch(`http://yoga/graphql/${persistedOperationKey}`); + + const body = await response.json(); + expect(body.errors).toBeUndefined(); + expect(body.data.__typename).toBe('Query'); + }); }); diff --git a/packages/plugins/persisted-operations/src/index.ts b/packages/plugins/persisted-operations/src/index.ts index 619b2d3d45..2cea685ceb 100644 --- a/packages/plugins/persisted-operations/src/index.ts +++ b/packages/plugins/persisted-operations/src/index.ts @@ -18,7 +18,10 @@ export interface GraphQLErrorOptions { extensions?: Maybe; } -export type ExtractPersistedOperationId = (params: GraphQLParams) => null | string; +export type ExtractPersistedOperationId = ( + params: GraphQLParams, + request: Request, +) => null | string; export const defaultExtractPersistedOperationId: ExtractPersistedOperationId = ( params: GraphQLParams, @@ -126,7 +129,7 @@ export function usePersistedOperations< return; } - const persistedOperationKey = extractPersistedOperationId(params); + const persistedOperationKey = extractPersistedOperationId(params, request); if (persistedOperationKey == null) { throw keyNotFoundErrorFactory(payload); diff --git a/website/src/pages/docs/features/persisted-operations.mdx b/website/src/pages/docs/features/persisted-operations.mdx index 461b6a3d9d..eedcb74c54 100644 --- a/website/src/pages/docs/features/persisted-operations.mdx +++ b/website/src/pages/docs/features/persisted-operations.mdx @@ -307,6 +307,132 @@ server.listen(4000, () => { }) ``` +## Advanced persisted operation id Extraction from HTTP Request + +You can extract the persisted operation id from the request using the `extractPersistedOperationId` + +### Query Parameters Recipe + +```ts filename="Extract persisted operation id from query parameters" {22-25} +import { createServer } from 'node:http' +import { createSchema, createYoga } from 'graphql-yoga' +import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' + +const store = { + ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38: '{__typename}' +} + +const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + ` + }), + plugins: [ + usePersistedOperations({ + getPersistedOperation(sha256Hash: string) { + return store[sha256Hash] + }, + extractPersistedOperationId(_params, request) { + const url = new URL(request.url) + return url.searchParams.get('id') + } + }) + ] +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +``` + +### Header Recipe + +You can also use the request headers to extract the persisted operation id. + +```ts filename="Extract persisted operation id from headers" {22-24} +import { createServer } from 'node:http' +import { createSchema, createYoga } from 'graphql-yoga' +import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' + +const store = { + ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38: '{__typename}' +} + +const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + ` + }), + plugins: [ + usePersistedOperations({ + getPersistedOperation(sha256Hash: string) { + return store[sha256Hash] + }, + extractPersistedOperationId(_params, request) { + return request.headers.get('x-document-id') + } + }) + ] +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +``` + +### 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](https://developer.mozilla.org/en-US/docs/Web/API/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. + +```ts filename="Extract persisted operation id from path" {10,23-25} +import { createServer } from 'node:http' +import { createSchema, createYoga } from 'graphql-yoga' +import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' + +const store = { + ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38: '{__typename}' +} + +const yoga = createYoga({ + graphqlEndpoint: '/graphql/:document_id?', + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + ` + }), + plugins: [ + usePersistedOperations({ + getPersistedOperation(sha256Hash: string) { + return store[sha256Hash] + }, + extractPersistedOperationId(_params, request) { + return request.url.split('/graphql/').pop() ?? null + } + }) + ] +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +``` + ## Using an external Persisted Operation Store As a project grows the amount of GraphQL Clients and GraphQL Operations can grow a lot. At some