diff --git a/.changeset/curvy-oranges-heal.md b/.changeset/curvy-oranges-heal.md new file mode 100644 index 000000000..6821702f2 --- /dev/null +++ b/.changeset/curvy-oranges-heal.md @@ -0,0 +1,5 @@ +--- +'graphql-yoga': minor +--- + +Pass the parsed request as-is and validate the final GraphQLParams in useCheckGraphQLParams diff --git a/packages/graphql-yoga/__tests__/requests.spec.ts b/packages/graphql-yoga/__tests__/requests.spec.ts index f1e734ba7..8cada4df9 100644 --- a/packages/graphql-yoga/__tests__/requests.spec.ts +++ b/packages/graphql-yoga/__tests__/requests.spec.ts @@ -258,4 +258,21 @@ describe('requests', () => { expect(body.errors).toBeUndefined() expect(body.data.ping).toBe('pong') }) + + it('errors if there is an invalid parameter in the request body', async () => { + const response = await yoga.fetch(`http://yoga/test-graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/graphql+json', + }, + body: JSON.stringify({ query: '{ ping }', test: 'a' }), + }) + + expect(response.status).toBe(400) + const body = JSON.parse(await response.text()) + expect(body.data).toBeUndefined() + expect(body.errors?.[0].message).toBe( + 'Unexpected parameter "test" in the request body.', + ) + }) }) diff --git a/packages/graphql-yoga/src/plugins/requestValidation/useCheckGraphQLQueryParams.ts b/packages/graphql-yoga/src/plugins/requestValidation/useCheckGraphQLQueryParams.ts index 17d5e5b1d..31367e0b1 100644 --- a/packages/graphql-yoga/src/plugins/requestValidation/useCheckGraphQLQueryParams.ts +++ b/packages/graphql-yoga/src/plugins/requestValidation/useCheckGraphQLQueryParams.ts @@ -2,6 +2,27 @@ import { createGraphQLError } from '@graphql-tools/utils' import { GraphQLParams } from '../../types' import { Plugin } from '../types' +const EXPECTED_PARAMS = ['query', 'variables', 'operationName', 'extensions'] + +export function assertInvalidParams( + params: any, +): asserts params is GraphQLParams { + for (const paramKey in params) { + if (!EXPECTED_PARAMS.includes(paramKey)) { + throw createGraphQLError( + `Unexpected parameter "${paramKey}" in the request body.`, + { + extensions: { + http: { + status: 400, + }, + }, + }, + ) + } + } +} + export function checkGraphQLQueryParams(params: unknown): GraphQLParams { if (!isObject(params)) { throw createGraphQLError( @@ -19,6 +40,8 @@ export function checkGraphQLQueryParams(params: unknown): GraphQLParams { ) } + assertInvalidParams(params) + if (params.query == null) { throw createGraphQLError('Must provide query string.', { extensions: { diff --git a/packages/plugins/apq/__tests__/automatic-persisted-queries.spec.ts b/packages/plugins/apq/__tests__/apq.spec.ts similarity index 100% rename from packages/plugins/apq/__tests__/automatic-persisted-queries.spec.ts rename to packages/plugins/apq/__tests__/apq.spec.ts diff --git a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts index f9f319288..07c9f4e69 100644 --- a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts +++ b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts @@ -1,4 +1,4 @@ -import { createYoga, createSchema } from 'graphql-yoga' +import { createYoga, createSchema, GraphQLParams } from 'graphql-yoga' import request from 'supertest' import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' @@ -10,13 +10,14 @@ const schema = createSchema({ `, }) -describe('Automatic Persisted Queries', () => { +describe('Persisted Operations', () => { it('should return not found error if persisted query is missing', async () => { - const store = new Map() const yoga = createYoga({ plugins: [ usePersistedOperations({ - store, + getPersistedOperation() { + return null + }, }), ], schema, @@ -42,7 +43,9 @@ describe('Automatic Persisted Queries', () => { const yoga = createYoga({ plugins: [ usePersistedOperations({ - store, + getPersistedOperation(key: string) { + return store.get(key) || null + }, }), ], schema, @@ -71,7 +74,9 @@ describe('Automatic Persisted Queries', () => { const yoga = createYoga({ plugins: [ usePersistedOperations({ - store, + getPersistedOperation(key: string) { + return store.get(key) || null + }, }), ], schema, @@ -97,7 +102,9 @@ describe('Automatic Persisted Queries', () => { const yoga = createYoga({ plugins: [ usePersistedOperations({ - store, + getPersistedOperation(key: string) { + return store.get(key) || null + }, allowArbitraryOperations: true, }), ], @@ -124,7 +131,9 @@ describe('Automatic Persisted Queries', () => { const yoga = createYoga({ plugins: [ usePersistedOperations({ - store, + getPersistedOperation(key: string) { + return store.get(key) || null + }, allowArbitraryOperations: (request) => request.headers.get('foo') === 'bar', }), @@ -149,4 +158,31 @@ describe('Automatic Persisted Queries', () => { expect(body.errors).toBeUndefined() expect(body.data).toEqual({ __typename: 'Query' }) }) + it('should respect the custom getPersistedQueryKey implementation (Relay)', async () => { + const store = new Map() + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null + }, + extractPersistedOperationId( + params: GraphQLParams & { doc_id?: string }, + ) { + return params.doc_id ?? null + }, + }), + ], + schema, + }) + const persistedOperationKey = 'my-persisted-operation' + store.set(persistedOperationKey, '{__typename}') + const response = await request(yoga).post('/graphql').send({ + doc_id: persistedOperationKey, + }) + + const body = JSON.parse(response.text) + 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 bfe515a46..9e38c9f07 100644 --- a/packages/plugins/persisted-operations/src/index.ts +++ b/packages/plugins/persisted-operations/src/index.ts @@ -1,25 +1,22 @@ -import { Plugin, PromiseOrValue } from 'graphql-yoga' +import { GraphQLParams, Plugin, PromiseOrValue } from 'graphql-yoga' import { GraphQLError } from 'graphql' -export interface PersistedOperationsStore { - get(key: string): PromiseOrValue -} - -export interface PersistedOperationExtension { - version: 1 - sha256Hash: string -} +export type ExtractPersistedOperationId = ( + params: GraphQLParams, +) => null | string -function decodePersistedOperationsExtension( - input: Record | null | undefined, -): null | PersistedOperationExtension { +export const defaultExtractPersistedOperationId: ExtractPersistedOperationId = ( + params: GraphQLParams, +): null | string => { if ( - input != null && - typeof input === 'object' && - input?.version === 1 && - typeof input?.sha256Hash === 'string' + params.extensions != null && + typeof params.extensions === 'object' && + params.extensions?.persistedQuery != null && + typeof params.extensions?.persistedQuery === 'object' && + params.extensions?.persistedQuery.version === 1 && + typeof params.extensions?.persistedQuery.sha256Hash === 'string' ) { - return input as PersistedOperationExtension + return params.extensions?.persistedQuery.sha256Hash } return null } @@ -30,21 +27,26 @@ type AllowArbitraryOperationsHandler = ( export interface UsePersistedOperationsOptions { /** - * Store for reading persisted operations. + * A function that fetches the persisted operation */ - store: PersistedOperationsStore + getPersistedOperation(key: string): PromiseOrValue /** * Whether to allow execution of arbitrary GraphQL operations aside from persisted operations. */ allowArbitraryOperations?: boolean | AllowArbitraryOperationsHandler + /** + * The path to the persisted operation id + */ + extractPersistedOperationId?: ExtractPersistedOperationId } -export function usePersistedOperations( - args: UsePersistedOperationsOptions, -): Plugin { - const allowArbitraryOperations = args.allowArbitraryOperations ?? false +export function usePersistedOperations({ + getPersistedOperation, + allowArbitraryOperations = false, + extractPersistedOperationId = defaultExtractPersistedOperationId, +}: UsePersistedOperationsOptions): Plugin { return { - async onParams({ params, request, setParams }) { + async onParams({ request, params, setParams }) { if (params.query) { if ( (typeof allowArbitraryOperations === 'boolean' @@ -56,21 +58,20 @@ export function usePersistedOperations( return } - const persistedQueryData = decodePersistedOperationsExtension( - params.extensions?.persistedQuery, - ) + const persistedOperationKey = extractPersistedOperationId(params) - if (persistedQueryData == null) { + if (persistedOperationKey == null) { throw new GraphQLError('PersistedQueryNotFound') } - const persistedQuery = await args.store.get(persistedQueryData.sha256Hash) + const persistedQuery = await getPersistedOperation(persistedOperationKey) if (persistedQuery == null) { throw new GraphQLError('PersistedQueryNotFound') } setParams({ - ...params, query: persistedQuery, + variables: params.variables, + extensions: params.extensions, }) }, } diff --git a/website/v3/docs/features/persisted-operations.mdx b/website/v3/docs/features/persisted-operations.mdx index 963fa24cc..f5fb77334 100644 --- a/website/v3/docs/features/persisted-operations.mdx +++ b/website/v3/docs/features/persisted-operations.mdx @@ -5,7 +5,8 @@ sidebar_label: Persisted Operations --- Persisted operations is a mechanism for preventing the execution of arbitary GraphQL operation documents. -The persisted operations plugin follows the [the APQ Specification of Apollo](https://github.com/apollographql/apollo-link-persisted-queries#apollo-engine) for **SENDING** hashes to the server. +By default, the persisted operations plugin follows the [the APQ Specification of Apollo](https://github.com/apollographql/apollo-link-persisted-queries#apollo-engine) for **SENDING** hashes to the server. +However, you can change this behavior by overriding the `getPersistedOperationKey` option to support Relay's specification for example. ## Installation @@ -18,15 +19,19 @@ import { createYoga } from 'graphql-yoga' import { createServer } from 'node:http' import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' -const store = new Map() - -store.set( - 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', - '{__typename}', -) +const store = { + ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38: + '{__typename}', +} const yoga = createYoga({ - plugins: [usePersistedOperations()], + plugins: [ + usePersistedOperations({ + getPersistedOperation(sha256Hash: string) { + return store[sha256Hash] + }, + }), + ], }) const server = createServer(yoga) @@ -65,10 +70,14 @@ import { createServer } from 'node:http' import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' import persistedOperations from './persistedOperations.json' -const store = new Map(Object.entries(persistedOperations)) - const yoga = createYoga({ - plugins: [usePersistedOperations()], + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return persistedOperations[key] + }, + }), + ], }) const server = createServer(yoga) @@ -101,3 +110,30 @@ usePersistedOperations({ ``` Use this option with caution! + +## Using Relay's Persisted Queries Specification + +If you are using [Relay's Persisted Queries specification](https://relay.dev/docs/guides/persisted-queries/#example-implemetation-of-relaylocalpersistingjs), you can configure the plugin like below; + +```ts +import { createYoga } from 'graphql-yoga' +import { createServer } from 'node:http' +import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' +import persistedOperations from './persistedOperations.json' + +const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperationKey(params: GraphQLParams & { doc_id: string }) { + return params.doc_id + } + getPersistedOperation(key: string) { + return persistedOperations[key] + }, + }), + ], +}) + +const server = createServer(yoga) +server.listen(4000) +```