Skip to content
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

Allow to customize persisted operations errors #2728

Merged
Merged
5 changes: 5 additions & 0 deletions .changeset/spotty-singers-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-persisted-operations': minor
---

allow to customize errors
Original file line number Diff line number Diff line change
@@ -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<string, string>()

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<string, string>()

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]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()

Expand Down Expand Up @@ -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<string, string>()

Expand Down Expand Up @@ -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<string, string>()
const yoga = createYoga({
Expand Down
72 changes: 67 additions & 5 deletions packages/plugins/persisted-operations/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<
Expand All @@ -60,31 +88,48 @@ export function usePersistedOperations<
extractPersistedOperationId = defaultExtractPersistedOperationId,
getPersistedOperation,
skipDocumentValidation = false,
customErrors,
}: UsePersistedOperationsOptions): Plugin<TPluginContext> {
const operationASTByRequest = new WeakMap<Request, DocumentNode>()
const persistedOperationRequest = new WeakSet<Request>()

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
}

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') {
Expand Down Expand Up @@ -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)
}
}
Loading