Skip to content

feat(response-cache): isPersistedDocumentContext helper #3951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/nine-bears-vanish.md
Original file line number Diff line number Diff line change
@@ -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
})
```
Original file line number Diff line number Diff line change
@@ -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 */ `
Expand Down Expand Up @@ -528,4 +531,146 @@
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<string, string>();
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<string, string>();
const yoga = createYoga({
plugins: [
usePersistedOperations({
getPersistedOperation(key: string) {
return store.get(key) || null;
},
allowArbitraryOperations: true,
}),
useExtendContext(() => ({ a: 121212 })),

Check failure on line 616 in packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts

View workflow job for this annotation

GitHub Actions / check

Invalid group length in numeric value
],
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,
},
});
});
});
});
17 changes: 16 additions & 1 deletion packages/plugins/persisted-operations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContext extends object>(context: TContext): boolean {
return isPersistedOperationContextSymbol in context;
}

function markContextAsPersistedOperationContext<TContext extends object>(context: TContext) {
(context as Record<string | symbol, unknown>)[isPersistedOperationContextSymbol] = true;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use a WeakMap with the context as key to mark it as persisted operation ?

}

export function usePersistedOperations<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TPluginContext extends Record<string, any>,
Expand All @@ -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) {
Expand Down Expand Up @@ -152,6 +165,8 @@ export function usePersistedOperations<
throw notFoundErrorFactory(payload);
}

markContextAsPersistedOperationContext(context);

if (typeof persistedQuery === 'object') {
setParams({
query: `__PERSISTED_OPERATION_${persistedOperationKey}__`,
Expand Down
44 changes: 44 additions & 0 deletions website/src/content/docs/features/persisted-operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading