Skip to content

Commit

Permalink
Allow to customize persisted operations errors (#2728)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
EmrysMyrddin authored May 8, 2023
1 parent 2b8e064 commit e6aa0dd
Show file tree
Hide file tree
Showing 5 changed files with 343 additions and 5 deletions.
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

0 comments on commit e6aa0dd

Please sign in to comment.