diff --git a/.changeset/nine-bears-vanish.md b/.changeset/nine-bears-vanish.md new file mode 100644 index 0000000000..da26f1d647 --- /dev/null +++ b/.changeset/nine-bears-vanish.md @@ -0,0 +1,43 @@ +--- +'@graphql-yoga/plugin-persisted-operations': minor +--- + +Add helper function `isPersistedDocumentContext`. + +This function can help you determine whether the GraphQL execution is done within the context of a +persisted document and for example apply additional plugins or security measures conditionally. + +**Usage Example: Enable max depth rule conditionally** + +```ts +import { createYoga } from 'graphql-yoga' +import { maxDepthRule } from '@escape.tech/graphql-armor-max-depth' +import { usePersistedOperations, isPersistedDocumentContext } from '@graphql-yoga/plugin-persisted-operations' +import schema from './schema.js' +import store from './store.js' + +const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null + }, + allowArbitraryOperations: true + }), + { + onValidate(ctx) { + if (isPersistedDocumentContext(ctx.context)) { + return + } + + ctx.addValidationRule( + maxDepthRule({ + n: 20 + }) + ) + } + } + ], + schema +}) +``` diff --git a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts index 4e9b0b5174..5c8ab8abdf 100644 --- a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts +++ b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts @@ -1,6 +1,9 @@ import { DocumentNode, parse, validate } from 'graphql'; -import { createSchema, createYoga, GraphQLParams } from 'graphql-yoga'; -import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'; +import { createSchema, createYoga, GraphQLParams, Plugin, useExtendContext } from 'graphql-yoga'; +import { + isPersistedOperationContext, + usePersistedOperations, +} from '@graphql-yoga/plugin-persisted-operations'; const schema = createSchema({ typeDefs: /* GraphQL */ ` @@ -528,4 +531,146 @@ describe('Persisted Operations', () => { expect(body.errors).toBeUndefined(); expect(body.data.__typename).toBe('Query'); }); + + describe('"isPersistedOperationContext" helper', () => { + it('returns true for persisted document context', async () => { + const store = new Map(); + const plugin: Plugin = { + onValidate(ctx) { + expect(isPersistedOperationContext(ctx.context)).toEqual(true); + }, + }; + + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null; + }, + allowArbitraryOperations: true, + }), + plugin, + ], + schema, + }); + const persistedOperationKey = 'my-persisted-operation'; + store.set(persistedOperationKey, '{__typename}'); + const result = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash: persistedOperationKey, + }, + }, + }), + }); + expect(result.status).toEqual(200); + await result.json(); + }); + it('returns false for non-persisted document context', async () => { + const plugin: Plugin = { + onValidate(ctx) { + expect(isPersistedOperationContext(ctx.context)).toEqual(false); + }, + }; + + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation() { + throw new Error('Not implemented'); + }, + allowArbitraryOperations: true, + }), + plugin, + ], + schema, + }); + const result = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: '{__typename}', + }), + }); + expect(result.status).toEqual(200); + await result.json(); + }); + it('information is retained if context is extended', async () => { + const store = new Map(); + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null; + }, + allowArbitraryOperations: true, + }), + useExtendContext(() => ({ a: 69 })), + ], + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + isPersistedOperationContext: Boolean! + } + `, + resolvers: { + Query: { + isPersistedOperationContext(_, __, context) { + return isPersistedOperationContext(context); + }, + }, + }, + }), + }); + + store.set('LOL', '{isPersistedOperationContext}'); + + let result = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'LOL', + }, + }, + }), + }); + expect(result.status).toEqual(200); + let body = await result.json(); + expect(body).toEqual({ + data: { + isPersistedOperationContext: true, + }, + }); + + result = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: '{isPersistedOperationContext}', + }), + }); + expect(result.status).toEqual(200); + body = await result.json(); + expect(body).toEqual({ + data: { + isPersistedOperationContext: false, + }, + }); + }); + }); }); diff --git a/packages/plugins/persisted-operations/src/index.ts b/packages/plugins/persisted-operations/src/index.ts index f4ca2c8221..91305a143a 100644 --- a/packages/plugins/persisted-operations/src/index.ts +++ b/packages/plugins/persisted-operations/src/index.ts @@ -94,6 +94,19 @@ export type CustomPersistedQueryErrors = { keyNotFound?: CustomErrorFactory; }; +const isPersistedOperationContextSymbol = Symbol.for('hive_is_persisted_operation_context'); + +/** + * Helper function for determining whether the execution is using a persisted document. + */ +export function isPersistedOperationContext(context: TContext): boolean { + return isPersistedOperationContextSymbol in context; +} + +function markContextAsPersistedOperationContext(context: TContext) { + (context as Record)[isPersistedOperationContextSymbol] = true; +} + export function usePersistedOperations< // eslint-disable-next-line @typescript-eslint/no-explicit-any TPluginContext extends Record, @@ -119,7 +132,7 @@ export function usePersistedOperations< return { onParams(payload) { - const { request, params, setParams } = payload; + const { request, params, setParams, context } = payload; if (params.query) { if (allowArbitraryOperations === false) { @@ -152,6 +165,8 @@ export function usePersistedOperations< throw notFoundErrorFactory(payload); } + markContextAsPersistedOperationContext(context); + if (typeof persistedQuery === 'object') { setParams({ query: `__PERSISTED_OPERATION_${persistedOperationKey}__`, diff --git a/website/src/content/docs/features/persisted-operations.mdx b/website/src/content/docs/features/persisted-operations.mdx index 940a81fab5..d72280be62 100644 --- a/website/src/content/docs/features/persisted-operations.mdx +++ b/website/src/content/docs/features/persisted-operations.mdx @@ -265,6 +265,50 @@ const plugin = usePersistedOperations({ Use this option with caution! +### Determine whether a request is using persisted documents + +Sometimes it is useful to run logic based on whether the execution context is using a persisted +operation or not. The `isPersistedOperationContext` helper function can determine that based on the +GraphQL execution context. + +**Usage Example: Enable max depth rule conditionally** + +```ts +import { createYoga } from 'graphql-yoga' +import { maxDepthRule } from '@escape.tech/graphql-armor-max-depth' +import { + isPersistedDocumentContext, + usePersistedOperations +} from '@graphql-yoga/plugin-persisted-operations' +import schema from './schema.js' +import store from './store.js' + +const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null + }, + allowArbitraryOperations: true + }), + { + onValidate(ctx) { + if (isPersistedDocumentContext(ctx.context)) { + return + } + + ctx.addValidationRule( + maxDepthRule({ + n: 20 + }) + ) + } + } + ], + schema +}) +``` + ## Using Relay's Persisted Queries Specification If you are using