From e6aa0dde36f7d5689eb466f17d80a7505a4f758b Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Mon, 8 May 2023 14:51:00 +0200 Subject: [PATCH] Allow to customize persisted operations errors (#2728) * customize not found error message * customize key not found and non-persistent operation rejection error * customize errors with graphql error options * customize errors with a function * add changeset * add documentation * give access to params and request in the error factory * create factories to avoid using slow typeof each time an error is thrown --- .changeset/spotty-singers-suffer.md | 5 + .../persisted-operations-errors.spec.ts | 222 ++++++++++++++++++ .../__tests__/persisted-operations.spec.ts | 3 + .../plugins/persisted-operations/src/index.ts | 72 +++++- .../docs/features/persisted-operations.mdx | 46 ++++ 5 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 .changeset/spotty-singers-suffer.md create mode 100644 packages/plugins/persisted-operations/__tests__/persisted-operations-errors.spec.ts diff --git a/.changeset/spotty-singers-suffer.md b/.changeset/spotty-singers-suffer.md new file mode 100644 index 0000000000..8d0ef97090 --- /dev/null +++ b/.changeset/spotty-singers-suffer.md @@ -0,0 +1,5 @@ +--- +'@graphql-yoga/plugin-persisted-operations': minor +--- + +allow to customize errors diff --git a/packages/plugins/persisted-operations/__tests__/persisted-operations-errors.spec.ts b/packages/plugins/persisted-operations/__tests__/persisted-operations-errors.spec.ts new file mode 100644 index 0000000000..c4c43e85ce --- /dev/null +++ b/packages/plugins/persisted-operations/__tests__/persisted-operations-errors.spec.ts @@ -0,0 +1,222 @@ +import { + CustomPersistedQueryErrors, + usePersistedOperations, +} from '@graphql-yoga/plugin-persisted-operations' +import { createSchema, createYoga, createGraphQLError } from 'graphql-yoga' + +const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + _: String + } + `, +}) + +describe('Persisted Operations', () => { + describe('Custom Errors', () => { + it('should allow to customize not found error message with a string', async () => { + const error = await generateNotFoundError({ notFound: 'Not found' }) + expect(error.message).toBe('Not found') + }) + + it('should allow to customize not found error message with error options', async () => { + const error = await generateNotFoundError({ + notFound: { + message: 'Not found', + extensions: { code: 'NOT_FOUND' }, + }, + }) + expect(error.message).toBe('Not found') + expect(error.extensions.code).toBe('NOT_FOUND') + }) + + it('should allow to customize not found error message with a function', async () => { + const error = await generateNotFoundError({ + notFound: () => + createGraphQLError('Not found', { + extensions: { code: 'NOT_FOUND' }, + }), + }) + expect(error.message).toBe('Not found') + expect(error.extensions.code).toBe('NOT_FOUND') + }) + + it('should allow to customize error when key is not found with a string', async () => { + const error = await generateKeyNotFoundError({ + keyNotFound: 'Key not found', + }) + expect(error.message).toBe('Key not found') + }) + + it('should allow to customize error when key is not found with error options', async () => { + const error = await generateKeyNotFoundError({ + keyNotFound: { + message: 'Key not found', + extensions: { code: 'KEY_NOT_FOUND' }, + }, + }) + expect(error.message).toBe('Key not found') + expect(error.extensions.code).toBe('KEY_NOT_FOUND') + }) + + it('should allow to customize error when key is not found with a function', async () => { + const error = await generateKeyNotFoundError({ + keyNotFound: () => + createGraphQLError('Key not found', { + extensions: { code: 'KEY_NOT_FOUND' }, + }), + }) + expect(error.message).toBe('Key not found') + expect(error.extensions.code).toBe('KEY_NOT_FOUND') + }) + + it('should allow to customize persisted query only error with a string', async () => { + const error = await generatePersistedQueryOnlyError({ + persistedQueryOnly: 'Persisted query only', + }) + expect(error.message).toBe('Persisted query only') + }) + + it('should allow to customize persisted query only error with error options', async () => { + const error = await generatePersistedQueryOnlyError({ + persistedQueryOnly: { + message: 'Persisted query only', + extensions: { code: 'PERSISTED_ONLY' }, + }, + }) + expect(error.message).toBe('Persisted query only') + expect(error.extensions.code).toBe('PERSISTED_ONLY') + }) + + it('should allow to customize persisted query only error with a function', async () => { + const error = await generatePersistedQueryOnlyError({ + persistedQueryOnly: () => + createGraphQLError('Persisted query only', { + extensions: { code: 'PERSISTED_ONLY' }, + }), + }) + expect(error.message).toBe('Persisted query only') + expect(error.extensions.code).toBe('PERSISTED_ONLY') + }) + }) +}) + +async function generateNotFoundError(customErrors: CustomPersistedQueryErrors) { + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation() { + return null + }, + customErrors, + }), + ], + schema, + }) + + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + }, + }, + }), + }) + + const body = await response.json() + expect(body.errors).toBeDefined() + return body.errors[0] +} + +async function generateKeyNotFoundError( + customErrors: CustomPersistedQueryErrors, +) { + const store = new Map() + + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key) { + return store.get(key) || null + }, + extractPersistedOperationId() { + return null + }, + customErrors, + }), + ], + schema, + }) + + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + store.set(persistedQueryEntry.sha256Hash, '{__typename}') + + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + }, + }, + }), + }) + + const body = await response.json() + expect(body.errors).toBeDefined() + return body.errors[0] +} + +async function generatePersistedQueryOnlyError( + customErrors: CustomPersistedQueryErrors, +) { + const store = new Map() + + const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + getPersistedOperation(key: string) { + return store.get(key) || null + }, + customErrors, + }), + ], + schema, + }) + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + store.set(persistedQueryEntry.sha256Hash, '{__typename}') + + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: '{__typename}', + }), + }) + + const body = await response.json() + expect(body.errors).toBeDefined() + return body.errors[0] +} diff --git a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts index 596b522717..bb86412124 100644 --- a/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts +++ b/packages/plugins/persisted-operations/__tests__/persisted-operations.spec.ts @@ -115,6 +115,7 @@ describe('Persisted Operations', () => { expect(body.errors).toBeDefined() expect(body.errors[0].message).toBe('PersistedQueryOnly') }) + it('allows non-persisted operations via allowArbitraryOperations flag', async () => { const store = new Map() @@ -150,6 +151,7 @@ describe('Persisted Operations', () => { expect(body.errors).toBeUndefined() expect(body.data).toEqual({ __typename: 'Query' }) }) + it('allows non-persisted operations via allowArbitraryOperations based on a header', async () => { const store = new Map() @@ -187,6 +189,7 @@ describe('Persisted Operations', () => { 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({ diff --git a/packages/plugins/persisted-operations/src/index.ts b/packages/plugins/persisted-operations/src/index.ts index 7601179ab7..7a254410eb 100644 --- a/packages/plugins/persisted-operations/src/index.ts +++ b/packages/plugins/persisted-operations/src/index.ts @@ -1,10 +1,11 @@ -import { DocumentNode } from 'graphql' +import { DocumentNode, GraphQLErrorOptions } from 'graphql' import { createGraphQLError, GraphQLParams, Plugin, PromiseOrValue, } from 'graphql-yoga' +import { OnParamsEventPayload } from 'graphql-yoga/src/plugins/types' export type ExtractPersistedOperationId = ( params: GraphQLParams, @@ -50,6 +51,33 @@ export type UsePersistedOperationsOptions = { * Whether to skip validation of the persisted operation */ skipDocumentValidation?: boolean + + /** + * Custom errors to be thrown + */ + customErrors?: CustomPersistedQueryErrors +} + +export type CustomErrorFactory = + | string + | (GraphQLErrorOptions & { message: string }) + | ((payload: OnParamsEventPayload) => Error) + +export type CustomPersistedQueryErrors = { + /** + * Error to be thrown when the persisted operation is not found + */ + notFound?: CustomErrorFactory + + /** + * Error to be thrown when rejecting non-persisted operations + */ + persistedQueryOnly?: CustomErrorFactory + + /** + * Error to be thrown when the extraction of the persisted operation id failed + */ + keyNotFound?: CustomErrorFactory } export function usePersistedOperations< @@ -60,18 +88,35 @@ export function usePersistedOperations< extractPersistedOperationId = defaultExtractPersistedOperationId, getPersistedOperation, skipDocumentValidation = false, + customErrors, }: UsePersistedOperationsOptions): Plugin { const operationASTByRequest = new WeakMap() const persistedOperationRequest = new WeakSet() + + const notFoundErrorFactory = createErrorFactory( + 'PersistedQueryNotFound', + customErrors?.notFound, + ) + const keyNotFoundErrorFactory = createErrorFactory( + 'PersistedQueryKeyNotFound', + customErrors?.keyNotFound, + ) + const persistentQueryOnlyErrorFactory = createErrorFactory( + 'PersistedQueryOnly', + customErrors?.persistedQueryOnly, + ) + return { - async onParams({ request, params, setParams }) { + async onParams(payload) { + const { request, params, setParams } = payload + if (params.query) { if ( (typeof allowArbitraryOperations === 'boolean' ? allowArbitraryOperations : await allowArbitraryOperations(request)) === false ) { - throw createGraphQLError('PersistedQueryOnly') + throw persistentQueryOnlyErrorFactory(payload) } return } @@ -79,12 +124,12 @@ export function usePersistedOperations< const persistedOperationKey = extractPersistedOperationId(params) if (persistedOperationKey == null) { - throw createGraphQLError('PersistedQueryNotFound') + throw keyNotFoundErrorFactory(payload) } const persistedQuery = await getPersistedOperation(persistedOperationKey) if (persistedQuery == null) { - throw createGraphQLError('PersistedQueryNotFound') + throw notFoundErrorFactory(payload) } if (typeof persistedQuery === 'object') { @@ -116,3 +161,20 @@ export function usePersistedOperations< }, } } + +function createErrorFactory( + defaultMessage: string, + options?: CustomErrorFactory, +) { + if (typeof options === 'string') { + return () => createGraphQLError(options) + } + + if (typeof options === 'function') { + return options + } + + return () => { + return createGraphQLError(options?.message ?? defaultMessage, options) + } +} diff --git a/website/src/pages/docs/features/persisted-operations.mdx b/website/src/pages/docs/features/persisted-operations.mdx index 0527dd2816..e02a5ab584 100644 --- a/website/src/pages/docs/features/persisted-operations.mdx +++ b/website/src/pages/docs/features/persisted-operations.mdx @@ -274,3 +274,49 @@ server.listen(4000, () => { console.info('Server is running on http://localhost:4000/graphql') }) ``` + +## Customize errors + +This plugin can throw three different types of errors:: + +- `PersistedOperationNotFound`: The persisted operation cannot be found. +- `PersistedOperationKeyNotFound`: The persistence key cannot be extracted from the request. +- `PersistedOperationOnly`: An arbitrary operation is rejected because only persisted operations are allowed. + +Each error can be customized to change the HTTP status or add a translation message ID, for example. + +```ts filename="Customize errors" +import { createYoga } from 'graphql-yoga' +import { createServer } from 'node:http' +import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations' +import { CustomErrorClass } from './custom-error-class' + +const yoga = createYoga({ + plugins: [ + usePersistedOperations({ + customErrors: { + // You can change the error message + notFound: 'Not Found', + // Or customize the error with a GraphqlError options object, allowing you to add extensions + keyNotFound: { + message: 'Key Not Found', + extensions: { + http: { + status: 404 + } + } + }, + // Or customize with a factory function allowing you to use your own error class or format + persistedQueryOnly: () => { + return new CustomErrorClass('Only Persisted Operations are allowed') + } + } + }) + ] +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +```