diff --git a/.changeset/real-sheep-end.md b/.changeset/real-sheep-end.md new file mode 100644 index 00000000000..28cbc627ab6 --- /dev/null +++ b/.changeset/real-sheep-end.md @@ -0,0 +1,6 @@ +--- +'@keystone-next/keystone': minor +'@keystone-next/types': minor +--- + +Add a `config.graphql.debug` option, which can be used to control with debug information such as stack traces are include in the errors returned by the GraphQL API. diff --git a/docs/pages/docs/apis/config.mdx b/docs/pages/docs/apis/config.mdx index aa881ffab0e..bab6cab20d9 100644 --- a/docs/pages/docs/apis/config.mdx +++ b/docs/pages/docs/apis/config.mdx @@ -277,6 +277,8 @@ It has a TypeScript type of `GraphQLConfig`. Options: +- `debug` (default: `process.env.NODE_ENV !== 'production'`): If `true`, stacktraces from both Apollo errors and Keystone errors will be included in the errors returned from the GraphQL API. + These can be filtered out with `apolloConfig.formatError` if you need to process them, but do not want them returned over the GraphQL API. - `queryLimits` (default: `undefined`): Allows you to limit the total number of results returned from a query to your GraphQL API. See also the per-list `graphql.queryLimits` option in the [Schema API](./schema). - `apolloConfig` (default: `undefined`): Allows you to pass extra options into the `ApolloServer` constructor. @@ -287,6 +289,7 @@ Options: ```typescript export default config({ graphql: { + debug: process.env.NODE_ENV !== 'production', queryLimits: { maxTotalResults: 100 }, apolloConfig: { playground: process.env.NODE_ENV !== 'production', diff --git a/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts b/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts index 0a032bfe62b..78ec432b4c4 100644 --- a/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts +++ b/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts @@ -15,7 +15,7 @@ export function nextGraphQLAPIRoute(keystoneConfig: KeystoneConfig, prismaClient graphQLSchema, createContext: keystone.createContext, sessionStrategy: initializedKeystoneConfig.session, - apolloConfig: initializedKeystoneConfig.graphql?.apolloConfig, + graphqlConfig: initializedKeystoneConfig.graphql, connectionPromise: keystone.connect(), }); diff --git a/packages/keystone/src/admin-ui/templates/api.ts b/packages/keystone/src/admin-ui/templates/api.ts index 01d75117313..c779651e582 100644 --- a/packages/keystone/src/admin-ui/templates/api.ts +++ b/packages/keystone/src/admin-ui/templates/api.ts @@ -9,7 +9,7 @@ const apolloServer = createApolloServerMicro({ graphQLSchema, createContext, sessionStrategy: initializedKeystoneConfig.session ? initializedKeystoneConfig.session() : undefined, - apolloConfig: initializedKeystoneConfig.graphql?.apolloConfig, + graphqlConfig: initializedKeystoneConfig.graphql, connectionPromise: keystone.connect(), }); diff --git a/packages/keystone/src/artifacts.ts b/packages/keystone/src/artifacts.ts index 5750545d5c8..665ce5d11af 100644 --- a/packages/keystone/src/artifacts.ts +++ b/packages/keystone/src/artifacts.ts @@ -10,6 +10,7 @@ import { ExitError } from './scripts/utils'; import { initialiseLists } from './lib/core/types-for-lists'; import { printPrismaSchema } from './lib/core/prisma-schema'; import { getDBProvider } from './lib/createSystem'; +import { graphQlErrors } from './lib/core/graphql-errors'; export function getSchemaPaths(cwd: string) { return { @@ -27,7 +28,8 @@ export async function getCommittedArtifacts( graphQLSchema: GraphQLSchema, config: KeystoneConfig ): Promise { - const lists = initialiseLists(config.lists, getDBProvider(config.db)); + const errors = graphQlErrors(config.graphql?.debug); + const lists = initialiseLists(config.lists, errors, getDBProvider(config.db)); const prismaSchema = printPrismaSchema( lists, getDBProvider(config.db), @@ -175,7 +177,11 @@ export async function generateNodeModulesArtifacts( config: KeystoneConfig, cwd: string ) { - const lists = initialiseLists(config.lists, getDBProvider(config.db)); + const lists = initialiseLists( + config.lists, + graphQlErrors(config.graphql?.debug), + getDBProvider(config.db) + ); const printedSchema = printSchema(graphQLSchema); const dotKeystoneDir = path.join(cwd, 'node_modules/.keystone'); diff --git a/packages/keystone/src/lib/core/graphql-errors.ts b/packages/keystone/src/lib/core/graphql-errors.ts index 33b58384f66..2b0f62d0b7c 100644 --- a/packages/keystone/src/lib/core/graphql-errors.ts +++ b/packages/keystone/src/lib/core/graphql-errors.ts @@ -1,28 +1,31 @@ import { ApolloError } from 'apollo-server-errors'; -export const accessDeniedError = () => new ApolloError('You do not have access to this resource'); - -export const validationFailureError = (messages: string[]) => { - const s = messages.map(m => ` - ${m}`).join('\n'); - return new ApolloError(`You provided invalid data for this operation.\n${s}`); -}; - -export const extensionError = (extension: string, things: { error: Error; tag: string }[]) => { - const s = things.map(t => ` - ${t.tag}: ${t.error.message}`).join('\n'); - return new ApolloError( - `An error occured while running "${extension}".\n${s}`, - 'INTERNAL_SERVER_ERROR', - // Make the original stack traces available in non-production modes. - // TODO: We need to have a way to make these stack traces available - // for logging in production mode. - process.env.NODE_ENV !== 'production' - ? { errors: things.map(t => ({ stacktrace: t.error.stack, message: t.error.message })) } - : undefined - ); +export type KeystoneErrors = { + accessDeniedError: any; + validationFailureError: any; + extensionError: any; + limitsExceededError: any; }; -// FIXME: In an upcoming PR we will use these args to construct a better -// error message, so leaving the, here for now. - TL // eslint-disable-next-line @typescript-eslint/no-unused-vars -export const limitsExceededError = (args: { type: string; limit: number; list: string }) => - new ApolloError('Your request exceeded server limits'); +export const graphQlErrors = (debug: boolean | undefined): KeystoneErrors => ({ + accessDeniedError: () => new ApolloError('You do not have access to this resource'), + validationFailureError: (messages: string[]) => { + const s = messages.map(m => ` - ${m}`).join('\n'); + return new ApolloError(`You provided invalid data for this operation.\n${s}`); + }, + extensionError: (extension: string, things: { error: Error; tag: string }[]) => { + const s = things.map(t => ` - ${t.tag}: ${t.error.message}`).join('\n'); + return new ApolloError( + `An error occured while running "${extension}".\n${s}`, + 'INTERNAL_SERVER_ERROR', + // Make the original stack traces available in non-production modes. + (debug === undefined ? process.env.NODE_ENV !== 'production' : debug) + ? { debug: things.map(t => ({ stacktrace: t.error.stack, message: t.error.message })) } + : undefined + ); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + limitsExceededError: (args: { type: string; limit: number; list: string }) => + new ApolloError('Your request exceeded server limits'), +}); diff --git a/packages/keystone/src/lib/core/graphql-schema.ts b/packages/keystone/src/lib/core/graphql-schema.ts index 9259400ad5c..6374198b10f 100644 --- a/packages/keystone/src/lib/core/graphql-schema.ts +++ b/packages/keystone/src/lib/core/graphql-schema.ts @@ -4,14 +4,16 @@ import { InitialisedList } from './types-for-lists'; import { getMutationsForList } from './mutations'; import { getQueriesForList } from './queries'; +import { KeystoneErrors } from './graphql-errors'; export function getGraphQLSchema( lists: Record, + errors: KeystoneErrors, provider: DatabaseProvider ) { const query = schema.object()({ name: 'Query', - fields: Object.assign({}, ...Object.values(lists).map(list => getQueriesForList(list))), + fields: Object.assign({}, ...Object.values(lists).map(list => getQueriesForList(list, errors))), }); const updateManyByList: Record> = {}; @@ -21,7 +23,7 @@ export function getGraphQLSchema( fields: Object.assign( {}, ...Object.values(lists).map(list => { - const { mutations, updateManyInput } = getMutationsForList(list, provider); + const { mutations, updateManyInput } = getMutationsForList(list, errors, provider); updateManyByList[list.listKey] = updateManyInput; return mutations; }) diff --git a/packages/keystone/src/lib/core/mutations/access-control.ts b/packages/keystone/src/lib/core/mutations/access-control.ts index 51efe776857..b024e119691 100644 --- a/packages/keystone/src/lib/core/mutations/access-control.ts +++ b/packages/keystone/src/lib/core/mutations/access-control.ts @@ -4,7 +4,7 @@ import { validateFieldAccessControl, validateNonCreateListAccessControl, } from '../access-control'; -import { accessDeniedError } from '../graphql-errors'; +import { KeystoneErrors } from '../graphql-errors'; import { mapUniqueWhereToWhere } from '../queries/resolvers'; import { InitialisedList } from '../types-for-lists'; import { runWithPrisma } from '../utils'; @@ -18,10 +18,16 @@ import { export async function getAccessControlledItemForDelete( list: InitialisedList, context: KeystoneContext, + errors: KeystoneErrors, uniqueInput: UniqueInputFilter, uniqueWhere: UniquePrismaFilter ): Promise { - const itemId = await getStringifiedItemIdFromUniqueWhereInput(uniqueInput, list.listKey, context); + const itemId = await getStringifiedItemIdFromUniqueWhereInput( + uniqueInput, + list.listKey, + context, + errors + ); // List access: pass 1 const access = await validateNonCreateListAccessControl({ @@ -29,7 +35,7 @@ export async function getAccessControlledItemForDelete( args: { context, listKey: list.listKey, operation: 'delete', session: context.session, itemId }, }); if (access === false) { - throw accessDeniedError(); + throw errors.accessDeniedError(); } // List access: pass 2 @@ -39,7 +45,7 @@ export async function getAccessControlledItemForDelete( } const item = await runWithPrisma(context, list, model => model.findFirst({ where })); if (item === null) { - throw accessDeniedError(); + throw errors.accessDeniedError(); } return item; @@ -48,11 +54,17 @@ export async function getAccessControlledItemForDelete( export async function getAccessControlledItemForUpdate( list: InitialisedList, context: KeystoneContext, + errors: KeystoneErrors, uniqueInput: UniqueInputFilter, uniqueWhere: UniquePrismaFilter, update: Record ) { - const itemId = await getStringifiedItemIdFromUniqueWhereInput(uniqueInput, list.listKey, context); + const itemId = await getStringifiedItemIdFromUniqueWhereInput( + uniqueInput, + list.listKey, + context, + errors + ); const args = { context, itemId, @@ -68,7 +80,7 @@ export async function getAccessControlledItemForUpdate( args, }); if (accessControl === false) { - throw accessDeniedError(); + throw errors.accessDeniedError(); } // List access: pass 2 @@ -82,7 +94,7 @@ export async function getAccessControlledItemForUpdate( }) ); if (!item) { - throw accessDeniedError(); + throw errors.accessDeniedError(); } // Field access @@ -97,7 +109,7 @@ export async function getAccessControlledItemForUpdate( ); if (results.some(canAccess => !canAccess)) { - throw accessDeniedError(); + throw errors.accessDeniedError(); } return item; @@ -106,6 +118,7 @@ export async function getAccessControlledItemForUpdate( export async function applyAccessControlForCreate( list: InitialisedList, context: KeystoneContext, + { accessDeniedError }: KeystoneErrors, originalInput: Record ) { const args = { @@ -141,7 +154,8 @@ export async function applyAccessControlForCreate( async function getStringifiedItemIdFromUniqueWhereInput( uniqueInput: UniqueInputFilter, listKey: string, - context: KeystoneContext + context: KeystoneContext, + { accessDeniedError }: KeystoneErrors ): Promise { if (uniqueInput.id !== undefined) { return uniqueInput.id; diff --git a/packages/keystone/src/lib/core/mutations/create-update.ts b/packages/keystone/src/lib/core/mutations/create-update.ts index eda831a0393..b7398054252 100644 --- a/packages/keystone/src/lib/core/mutations/create-update.ts +++ b/packages/keystone/src/lib/core/mutations/create-update.ts @@ -9,6 +9,7 @@ import { runWithPrisma, } from '../utils'; import { resolveUniqueWhereInput, UniqueInputFilter } from '../where-inputs'; +import { KeystoneErrors } from '../graphql-errors'; import { resolveRelateToManyForCreateInput, resolveRelateToManyForUpdateInput, @@ -25,15 +26,17 @@ async function createSingle( { data: rawData }: { data: Record }, list: InitialisedList, context: KeystoneContext, - writeLimit: Limit + writeLimit: Limit, + errors: KeystoneErrors ) { - await applyAccessControlForCreate(list, context, rawData); + await applyAccessControlForCreate(list, context, errors, rawData); const { afterChange, data } = await resolveInputForCreateOrUpdate( list, context, rawData, - undefined + undefined, + errors ); const item = await writeLimit(() => @@ -46,14 +49,22 @@ async function createSingle( export class NestedMutationState { #afterChanges: (() => void | Promise)[] = []; #context: KeystoneContext; - constructor(context: KeystoneContext) { + #errors: KeystoneErrors; + constructor(context: KeystoneContext, errors: KeystoneErrors) { this.#context = context; + this.#errors = errors; } async create(data: Record, list: InitialisedList) { const context = this.#context; const writeLimit = pLimit(1); - const { item, afterChange } = await createSingle({ data }, list, context, writeLimit); + const { item, afterChange } = await createSingle( + { data }, + list, + context, + writeLimit, + this.#errors + ); this.#afterChanges.push(() => afterChange(item)); return { id: item.id as IdType }; @@ -67,11 +78,12 @@ export class NestedMutationState { export async function createOne( createInput: { data: Record }, list: InitialisedList, - context: KeystoneContext + context: KeystoneContext, + errors: KeystoneErrors ) { const writeLimit = pLimit(1); - const { item, afterChange } = await createSingle(createInput, list, context, writeLimit); + const { item, afterChange } = await createSingle(createInput, list, context, writeLimit, errors); await afterChange(item); @@ -82,11 +94,12 @@ export function createMany( createInputs: { data: Record[] }, list: InitialisedList, context: KeystoneContext, - provider: DatabaseProvider + provider: DatabaseProvider, + errors: KeystoneErrors ) { const writeLimit = pLimit(provider === 'sqlite' ? 1 : Infinity); return createInputs.data.map(async data => { - const { item, afterChange } = await createSingle({ data }, list, context, writeLimit); + const { item, afterChange } = await createSingle({ data }, list, context, writeLimit, errors); await afterChange(item); @@ -98,7 +111,8 @@ async function updateSingle( updateInput: { where: UniqueInputFilter; data: Record }, list: InitialisedList, context: KeystoneContext, - writeLimit: Limit + writeLimit: Limit, + errors: KeystoneErrors ) { const { where: uniqueInput, data: rawData } = updateInput; // Validate and resolve the input filter @@ -108,12 +122,19 @@ async function updateSingle( const item = await getAccessControlledItemForUpdate( list, context, + errors, uniqueInput, uniqueWhere, rawData ); - const { afterChange, data } = await resolveInputForCreateOrUpdate(list, context, rawData, item); + const { afterChange, data } = await resolveInputForCreateOrUpdate( + list, + context, + rawData, + item, + errors + ); const updatedItem = await writeLimit(() => runWithPrisma(context, list, model => model.update({ where: { id: item.id }, data })) @@ -127,20 +148,24 @@ async function updateSingle( export async function updateOne( updateInput: { where: UniqueInputFilter; data: Record }, list: InitialisedList, - context: KeystoneContext + context: KeystoneContext, + errors: KeystoneErrors ) { const writeLimit = pLimit(1); - return updateSingle(updateInput, list, context, writeLimit); + return updateSingle(updateInput, list, context, writeLimit, errors); } export function updateMany( { data }: { data: { where: UniqueInputFilter; data: Record }[] }, list: InitialisedList, context: KeystoneContext, - provider: DatabaseProvider + provider: DatabaseProvider, + errors: KeystoneErrors ) { const writeLimit = pLimit(provider === 'sqlite' ? 1 : Infinity); - return data.map(async updateInput => updateSingle(updateInput, list, context, writeLimit)); + return data.map(async updateInput => + updateSingle(updateInput, list, context, writeLimit, errors) + ); } async function getResolvedData( @@ -254,10 +279,11 @@ async function resolveInputForCreateOrUpdate( list: InitialisedList, context: KeystoneContext, originalInput: Record, - existingItem: Record | undefined + existingItem: Record | undefined, + errors: KeystoneErrors ) { const operation: 'create' | 'update' = existingItem === undefined ? 'create' : 'update'; - const nestedMutationState = new NestedMutationState(context); + const nestedMutationState = new NestedMutationState(context, errors); const { listKey } = list; const hookArgs = { context, @@ -273,10 +299,10 @@ async function resolveInputForCreateOrUpdate( hookArgs.resolvedData = await getResolvedData(list, hookArgs, nestedMutationState); // Apply all validation checks - await validateUpdateCreate({ list, hookArgs }); + await validateUpdateCreate({ list, errors, hookArgs }); // Run beforeChange hooks - await runSideEffectOnlyHook(list, 'beforeChange', hookArgs); + await runSideEffectOnlyHook(list, 'beforeChange', hookArgs, errors); // Return the full resolved input (ready for prisma level operation), // and the afterChange hook to be applied @@ -284,7 +310,12 @@ async function resolveInputForCreateOrUpdate( data: flattenMultiDbFields(list.fields, hookArgs.resolvedData), afterChange: async (updatedItem: ItemRootValue) => { await nestedMutationState.afterChange(); - await runSideEffectOnlyHook(list, 'afterChange', { ...hookArgs, updatedItem, existingItem }); + await runSideEffectOnlyHook( + list, + 'afterChange', + { ...hookArgs, updatedItem, existingItem }, + errors + ); }, }; } diff --git a/packages/keystone/src/lib/core/mutations/delete.ts b/packages/keystone/src/lib/core/mutations/delete.ts index 1d24a15aee9..634932eed7f 100644 --- a/packages/keystone/src/lib/core/mutations/delete.ts +++ b/packages/keystone/src/lib/core/mutations/delete.ts @@ -1,5 +1,6 @@ import { KeystoneContext, DatabaseProvider } from '@keystone-next/types'; import pLimit, { Limit } from 'p-limit'; +import { KeystoneErrors } from '../graphql-errors'; import { InitialisedList } from '../types-for-lists'; import { runWithPrisma } from '../utils'; import { resolveUniqueWhereInput, UniqueInputFilter } from '../where-inputs'; @@ -11,7 +12,8 @@ async function deleteSingle( uniqueInput: UniqueInputFilter, list: InitialisedList, context: KeystoneContext, - writeLimit: Limit + writeLimit: Limit, + errors: KeystoneErrors ) { // Validate and resolve the input filter const uniqueWhere = await resolveUniqueWhereInput(uniqueInput, list.fields, context); @@ -20,6 +22,7 @@ async function deleteSingle( const existingItem = await getAccessControlledItemForDelete( list, context, + errors, uniqueInput, uniqueWhere ); @@ -27,16 +30,16 @@ async function deleteSingle( const hookArgs = { operation: 'delete' as const, listKey: list.listKey, context, existingItem }; // Apply all validation checks - await validateDelete({ list, hookArgs }); + await validateDelete({ list, errors, hookArgs }); // Before delete - await runSideEffectOnlyHook(list, 'beforeDelete', hookArgs); + await runSideEffectOnlyHook(list, 'beforeDelete', hookArgs, errors); const item = await writeLimit(() => runWithPrisma(context, list, model => model.delete({ where: { id: existingItem.id } })) ); - await runSideEffectOnlyHook(list, 'afterDelete', hookArgs); + await runSideEffectOnlyHook(list, 'afterDelete', hookArgs, errors); return item; } @@ -45,18 +48,20 @@ export function deleteMany( uniqueInputs: UniqueInputFilter[], list: InitialisedList, context: KeystoneContext, - provider: DatabaseProvider + provider: DatabaseProvider, + errors: KeystoneErrors ) { const writeLimit = pLimit(provider === 'sqlite' ? 1 : Infinity); return uniqueInputs.map(async uniqueInput => - deleteSingle(uniqueInput, list, context, writeLimit) + deleteSingle(uniqueInput, list, context, writeLimit, errors) ); } export async function deleteOne( uniqueInput: UniqueInputFilter, list: InitialisedList, - context: KeystoneContext + context: KeystoneContext, + errors: KeystoneErrors ) { - return deleteSingle(uniqueInput, list, context, pLimit(1)); + return deleteSingle(uniqueInput, list, context, pLimit(1), errors); } diff --git a/packages/keystone/src/lib/core/mutations/hooks.ts b/packages/keystone/src/lib/core/mutations/hooks.ts index eeac512823f..8082ffcaa90 100644 --- a/packages/keystone/src/lib/core/mutations/hooks.ts +++ b/packages/keystone/src/lib/core/mutations/hooks.ts @@ -1,4 +1,4 @@ -import { extensionError } from '../graphql-errors'; +import { KeystoneErrors } from '../graphql-errors'; export async function runSideEffectOnlyHook< HookName extends string, @@ -17,7 +17,7 @@ export async function runSideEffectOnlyHook< listKey: string; }, Args extends Parameters>[0] ->(list: List, hookName: HookName, args: Args) { +>(list: List, hookName: HookName, args: Args, { extensionError }: KeystoneErrors) { // Runs the before/after change/delete hooks // Only run field hooks on change operations if the field diff --git a/packages/keystone/src/lib/core/mutations/index.ts b/packages/keystone/src/lib/core/mutations/index.ts index da3d67b8860..794b889cf0c 100644 --- a/packages/keystone/src/lib/core/mutations/index.ts +++ b/packages/keystone/src/lib/core/mutations/index.ts @@ -1,4 +1,5 @@ import { DatabaseProvider, getGqlNames, schema } from '@keystone-next/types'; +import { KeystoneErrors } from '../graphql-errors'; import { InitialisedList } from '../types-for-lists'; import * as createAndUpdate from './create-update'; import * as deletes from './delete'; @@ -19,14 +20,18 @@ function promisesButSettledWhenAllSettledAndInOrder[] }) as T; } -export function getMutationsForList(list: InitialisedList, provider: DatabaseProvider) { +export function getMutationsForList( + list: InitialisedList, + errors: KeystoneErrors, + provider: DatabaseProvider +) { const names = getGqlNames(list); const createOne = schema.field({ type: list.types.output, args: { data: schema.arg({ type: schema.nonNull(list.types.create) }) }, resolve(_rootVal, { data }, context) { - return createAndUpdate.createOne({ data }, list, context); + return createAndUpdate.createOne({ data }, list, context, errors); }, }); @@ -37,7 +42,7 @@ export function getMutationsForList(list: InitialisedList, provider: DatabasePro }, resolve(_rootVal, args, context) { return promisesButSettledWhenAllSettledAndInOrder( - createAndUpdate.createMany(args, list, context, provider) + createAndUpdate.createMany(args, list, context, provider, errors) ); }, }); @@ -49,7 +54,7 @@ export function getMutationsForList(list: InitialisedList, provider: DatabasePro data: schema.arg({ type: schema.nonNull(list.types.update) }), }, resolve(_rootVal, args, context) { - return createAndUpdate.updateOne(args, list, context); + return createAndUpdate.updateOne(args, list, context, errors); }, }); @@ -67,7 +72,7 @@ export function getMutationsForList(list: InitialisedList, provider: DatabasePro }, resolve(_rootVal, args, context) { return promisesButSettledWhenAllSettledAndInOrder( - createAndUpdate.updateMany(args, list, context, provider) + createAndUpdate.updateMany(args, list, context, provider, errors) ); }, }); @@ -76,7 +81,7 @@ export function getMutationsForList(list: InitialisedList, provider: DatabasePro type: list.types.output, args: { where: schema.arg({ type: schema.nonNull(list.types.uniqueWhere) }) }, resolve(rootVal, { where }, context) { - return deletes.deleteOne(where, list, context); + return deletes.deleteOne(where, list, context, errors); }, }); @@ -89,7 +94,7 @@ export function getMutationsForList(list: InitialisedList, provider: DatabasePro }, resolve(rootVal, { where }, context) { return promisesButSettledWhenAllSettledAndInOrder( - deletes.deleteMany(where, list, context, provider) + deletes.deleteMany(where, list, context, provider, errors) ); }, }); diff --git a/packages/keystone/src/lib/core/mutations/validation.ts b/packages/keystone/src/lib/core/mutations/validation.ts index bfb5d19d2e5..aefcdc2a510 100644 --- a/packages/keystone/src/lib/core/mutations/validation.ts +++ b/packages/keystone/src/lib/core/mutations/validation.ts @@ -1,9 +1,10 @@ -import { validationFailureError } from '../graphql-errors'; +import { KeystoneErrors } from '../graphql-errors'; import { InitialisedList } from '../types-for-lists'; type AddValidationError = (msg: string) => void; async function validationHook( + { validationFailureError }: KeystoneErrors, _validationHook: (addValidationError: AddValidationError) => void | Promise ) { const messages: string[] = []; @@ -22,13 +23,15 @@ type UpdateCreateHookArgs = Parameters< >[0]; export async function validateUpdateCreate({ list, + errors, hookArgs, }: { list: InitialisedList; + errors: KeystoneErrors; hookArgs: Omit; }) { const { operation, resolvedData } = hookArgs; - await validationHook(async _addValidationError => { + await validationHook(errors, async _addValidationError => { // Check isRequired for (const [fieldKey, field] of Object.entries(list.fields)) { const addValidationError = (msg: string) => @@ -69,12 +72,14 @@ export async function validateUpdateCreate({ type DeleteHookArgs = Parameters>[0]; export async function validateDelete({ list, + errors, hookArgs, }: { list: InitialisedList; + errors: KeystoneErrors; hookArgs: Omit; }) { - await validationHook(async _addValidationError => { + await validationHook(errors, async _addValidationError => { // Field validation for (const [fieldPath, field] of Object.entries(list.fields)) { const addValidationError = (msg: string) => diff --git a/packages/keystone/src/lib/core/queries/index.ts b/packages/keystone/src/lib/core/queries/index.ts index 4e335310f11..71a04f2e458 100644 --- a/packages/keystone/src/lib/core/queries/index.ts +++ b/packages/keystone/src/lib/core/queries/index.ts @@ -1,8 +1,9 @@ import { getGqlNames, schema } from '@keystone-next/types'; +import { KeystoneErrors } from '../graphql-errors'; import { InitialisedList } from '../types-for-lists'; import * as queries from './resolvers'; -export function getQueriesForList(list: InitialisedList) { +export function getQueriesForList(list: InitialisedList, errors: KeystoneErrors) { if (list.access.read === false) return {}; const names = getGqlNames(list); @@ -10,7 +11,7 @@ export function getQueriesForList(list: InitialisedList) { type: list.types.output, args: { where: schema.arg({ type: schema.nonNull(list.types.uniqueWhere) }) }, async resolve(_rootVal, args, context) { - return queries.findOne(args, list, context); + return queries.findOne(args, list, context, errors); }, }); @@ -18,7 +19,7 @@ export function getQueriesForList(list: InitialisedList) { type: schema.list(schema.nonNull(list.types.output)), args: list.types.findManyArgs, async resolve(_rootVal, args, context, info) { - return queries.findMany(args, list, context, info); + return queries.findMany(args, list, context, info, errors); }, }); @@ -26,7 +27,7 @@ export function getQueriesForList(list: InitialisedList) { type: schema.Int, args: { where: schema.arg({ type: schema.nonNull(list.types.where), defaultValue: {} }) }, async resolve(_rootVal, args, context, info) { - return queries.count(args, list, context, info); + return queries.count(args, list, context, info, errors); }, }); diff --git a/packages/keystone/src/lib/core/queries/output-field.ts b/packages/keystone/src/lib/core/queries/output-field.ts index c927730f07a..8baf86357e9 100644 --- a/packages/keystone/src/lib/core/queries/output-field.ts +++ b/packages/keystone/src/lib/core/queries/output-field.ts @@ -12,10 +12,10 @@ import { } from '@keystone-next/types'; import { GraphQLResolveInfo } from 'graphql'; import { validateFieldAccessControl } from '../access-control'; -import { accessDeniedError } from '../graphql-errors'; import { ResolvedDBField, ResolvedRelationDBField } from '../resolve-relationships'; import { InitialisedList } from '../types-for-lists'; import { IdType, getDBFieldKeyForFieldOnMultiField, runWithPrisma } from '../utils'; +import { KeystoneErrors } from '../graphql-errors'; import { accessControlledFilter } from './resolvers'; import * as queries from './resolvers'; @@ -24,7 +24,8 @@ function getRelationVal( id: IdType, foreignList: InitialisedList, context: KeystoneContext, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, + errors: KeystoneErrors ) { const oppositeDbField = foreignList.resolvedDbFields[dbField.field]; if (oppositeDbField.kind !== 'relation') throw new Error('failed assert'); @@ -34,13 +35,18 @@ function getRelationVal( if (dbField.mode === 'many') { return { findMany: async (args: FindManyArgsValue) => - queries.findMany(args, foreignList, context, info, relationFilter), + queries.findMany(args, foreignList, context, info, errors, relationFilter), count: async ({ where }: { where: TypesForList['where'] }) => - queries.count({ where }, foreignList, context, info, relationFilter), + queries.count({ where }, foreignList, context, info, errors, relationFilter), }; } else { return async () => { - const resolvedWhere = await accessControlledFilter(foreignList, context, relationFilter); + const resolvedWhere = await accessControlledFilter( + foreignList, + context, + relationFilter, + errors + ); return runWithPrisma(context, foreignList, model => model.findFirst({ where: resolvedWhere }) @@ -56,7 +62,8 @@ function getValueForDBField( fieldPath: string, context: KeystoneContext, lists: Record, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, + errors: KeystoneErrors ) { if (dbField.kind === 'multi') { return Object.fromEntries( @@ -67,7 +74,7 @@ function getValueForDBField( ); } if (dbField.kind === 'relation') { - return getRelationVal(dbField, id, lists[dbField.list], context, info); + return getRelationVal(dbField, id, lists[dbField.list], context, info, errors); } else { return rootVal[fieldPath] as any; } @@ -80,7 +87,8 @@ export function outputTypeField( access: IndividualFieldAccessControl>, listKey: string, fieldKey: string, - lists: Record + lists: Record, + errors: KeystoneErrors ) { return schema.field({ type: output.type, @@ -107,7 +115,7 @@ export function outputTypeField( // If the client handles errors correctly, it should be able to // receive partial data (for the fields the user has access to), // and then an `errors` array of AccessDeniedError's - throw accessDeniedError(); + throw errors.accessDeniedError(); } // Only static cache hints are supported at the field level until a use-case makes it clear what parameters a dynamic hint would take @@ -115,7 +123,16 @@ export function outputTypeField( info.cacheControl.setCacheHint(cacheHint as any); } - const value = getValueForDBField(rootVal, dbField, id, fieldKey, context, lists, info); + const value = getValueForDBField( + rootVal, + dbField, + id, + fieldKey, + context, + lists, + info, + errors + ); if (output.resolve) { return output.resolve({ value, item: rootVal }, args, context, info); diff --git a/packages/keystone/src/lib/core/queries/resolvers.ts b/packages/keystone/src/lib/core/queries/resolvers.ts index dcfcae416cb..56290ae3a6a 100644 --- a/packages/keystone/src/lib/core/queries/resolvers.ts +++ b/packages/keystone/src/lib/core/queries/resolvers.ts @@ -13,9 +13,9 @@ import { resolveWhereInput, UniqueInputFilter, } from '../where-inputs'; -import { accessDeniedError, limitsExceededError } from '../graphql-errors'; import { InitialisedList } from '../types-for-lists'; import { getDBFieldKeyForFieldOnMultiField, runWithPrisma } from '../utils'; +import { KeystoneErrors } from '../graphql-errors'; // doing this is a result of an optimisation to skip doing a findUnique and then a findFirst(where the second one is done with access control) // we want to do this explicit mapping because: @@ -46,7 +46,8 @@ export function mapUniqueWhereToWhere( export async function accessControlledFilter( list: InitialisedList, context: KeystoneContext, - resolvedWhere: PrismaFilter + resolvedWhere: PrismaFilter, + { accessDeniedError }: KeystoneErrors ) { // Run access control const access = await validateNonCreateListAccessControl({ @@ -68,19 +69,20 @@ export async function accessControlledFilter( export async function findOne( args: { where: UniqueInputFilter }, list: InitialisedList, - context: KeystoneContext + context: KeystoneContext, + errors: KeystoneErrors ) { // Validate and resolve the input filter const uniqueWhere = await resolveUniqueWhereInput(args.where, list.fields, context); const resolvedWhere = mapUniqueWhereToWhere(list, uniqueWhere); // Apply access control - const filter = await accessControlledFilter(list, context, resolvedWhere); + const filter = await accessControlledFilter(list, context, resolvedWhere, errors); const item = await runWithPrisma(context, list, model => model.findFirst({ where: filter })); if (item === null) { - throw accessDeniedError(); + throw errors.accessDeniedError(); } return item; } @@ -90,14 +92,15 @@ export async function findMany( list: InitialisedList, context: KeystoneContext, info: GraphQLResolveInfo, + errors: KeystoneErrors, extraFilter?: PrismaFilter ): Promise { const orderBy = await resolveOrderBy(rawOrderBy, list, context); - applyEarlyMaxResults(take, list); + applyEarlyMaxResults(take, list, errors); let resolvedWhere = await resolveWhereInput(where || {}, list); - resolvedWhere = await accessControlledFilter(list, context, resolvedWhere); + resolvedWhere = await accessControlledFilter(list, context, resolvedWhere, errors); const results = await runWithPrisma(context, list, model => model.findMany({ @@ -108,7 +111,7 @@ export async function findMany( }) ); - applyMaxResults(results, list, context); + applyMaxResults(results, list, context, errors); if (info.cacheControl && list.cacheHint) { info.cacheControl.setCacheHint( @@ -164,10 +167,11 @@ export async function count( list: InitialisedList, context: KeystoneContext, info: GraphQLResolveInfo, + errors: KeystoneErrors, extraFilter?: PrismaFilter ) { let resolvedWhere = await resolveWhereInput(where || {}, list); - resolvedWhere = await accessControlledFilter(list, context, resolvedWhere); + resolvedWhere = await accessControlledFilter(list, context, resolvedWhere, errors); const count = await runWithPrisma(context, list, model => model.count({ @@ -186,7 +190,11 @@ export async function count( return count; } -function applyEarlyMaxResults(_take: number | null | undefined, list: InitialisedList) { +function applyEarlyMaxResults( + _take: number | null | undefined, + list: InitialisedList, + { limitsExceededError }: KeystoneErrors +) { const take = _take ?? Infinity; // We want to help devs by failing fast and noisily if limits are violated. // Unfortunately, we can't always be sure of intent. @@ -200,7 +208,12 @@ function applyEarlyMaxResults(_take: number | null | undefined, list: Initialise } } -function applyMaxResults(results: unknown[], list: InitialisedList, context: KeystoneContext) { +function applyMaxResults( + results: unknown[], + list: InitialisedList, + context: KeystoneContext, + { limitsExceededError }: KeystoneErrors +) { if (results.length > list.maxResults) { throw limitsExceededError({ list: list.listKey, type: 'maxResults', limit: list.maxResults }); } diff --git a/packages/keystone/src/lib/core/types-for-lists.ts b/packages/keystone/src/lib/core/types-for-lists.ts index 518d102c14b..3c0b8d013f0 100644 --- a/packages/keystone/src/lib/core/types-for-lists.ts +++ b/packages/keystone/src/lib/core/types-for-lists.ts @@ -25,6 +25,7 @@ import { ResolvedDBField, resolveRelationships } from './resolve-relationships'; import { InputFilter, PrismaFilter, resolveWhereInput } from './where-inputs'; import { outputTypeField } from './queries/output-field'; import { assertFieldsValid } from './field-assertions'; +import { KeystoneErrors } from './graphql-errors'; export type InitialisedField = Omit & { dbField: ResolvedDBField; @@ -50,6 +51,7 @@ export type InitialisedList = { export function initialiseLists( listsConfig: KeystoneConfig['lists'], + errors: KeystoneErrors, provider: DatabaseProvider ): Record { const listInfos: Record = {}; @@ -80,7 +82,8 @@ export function initialiseLists( field.access.read, listKey, fieldPath, - lists + lists, + errors ), ]; }); diff --git a/packages/keystone/src/lib/createGraphQLSchema.ts b/packages/keystone/src/lib/createGraphQLSchema.ts index 0de192ec3d4..70bc38b1616 100644 --- a/packages/keystone/src/lib/createGraphQLSchema.ts +++ b/packages/keystone/src/lib/createGraphQLSchema.ts @@ -4,14 +4,16 @@ import { sessionSchema } from '../session'; import { InitialisedList } from './core/types-for-lists'; import { getGraphQLSchema } from './core/graphql-schema'; import { getDBProvider } from './createSystem'; +import { KeystoneErrors } from './core/graphql-errors'; export function createGraphQLSchema( config: KeystoneConfig, lists: Record, + errors: KeystoneErrors, adminMeta: AdminMetaRootVal ) { // Start with the core keystone graphQL schema - let graphQLSchema = getGraphQLSchema(lists, getDBProvider(config.db)); + let graphQLSchema = getGraphQLSchema(lists, errors, getDBProvider(config.db)); // Merge in the user defined graphQL API if (config.extendGraphqlSchema) { diff --git a/packages/keystone/src/lib/createSystem.ts b/packages/keystone/src/lib/createSystem.ts index 1a0a6114dcf..533f53030d1 100644 --- a/packages/keystone/src/lib/createSystem.ts +++ b/packages/keystone/src/lib/createSystem.ts @@ -4,6 +4,7 @@ import { createAdminMeta } from '../admin-ui/system/createAdminMeta'; import { createGraphQLSchema } from './createGraphQLSchema'; import { makeCreateContext } from './context/createContext'; import { initialiseLists } from './core/types-for-lists'; +import { graphQlErrors, KeystoneErrors } from './core/graphql-errors'; export function getDBProvider(db: KeystoneConfig['db']): DatabaseProvider { if (db.adapter === 'prisma_postgresql' || db.provider === 'postgresql') { @@ -17,7 +18,11 @@ export function getDBProvider(db: KeystoneConfig['db']): DatabaseProvider { } } -function getInternalGraphQLSchema(config: KeystoneConfig, provider: DatabaseProvider) { +function getInternalGraphQLSchema( + config: KeystoneConfig, + errors: KeystoneErrors, + provider: DatabaseProvider +) { const transformedConfig: KeystoneConfig = { ...config, lists: Object.fromEntries( @@ -42,20 +47,23 @@ function getInternalGraphQLSchema(config: KeystoneConfig, provider: DatabaseProv }) ), }; - const lists = initialiseLists(transformedConfig.lists, provider); + const lists = initialiseLists(transformedConfig.lists, errors, provider); const adminMeta = createAdminMeta(transformedConfig, lists); - return createGraphQLSchema(transformedConfig, lists, adminMeta); + return createGraphQLSchema(transformedConfig, lists, errors, adminMeta); } export function createSystem(config: KeystoneConfig) { const provider = getDBProvider(config.db); - const lists = initialiseLists(config.lists, provider); + + const errors = graphQlErrors(config.graphql?.debug); + + const lists = initialiseLists(config.lists, errors, provider); const adminMeta = createAdminMeta(config, lists); - const graphQLSchema = createGraphQLSchema(config, lists, adminMeta); + const graphQLSchema = createGraphQLSchema(config, lists, errors, adminMeta); - const internalGraphQLSchema = getInternalGraphQLSchema(config, provider); + const internalGraphQLSchema = getInternalGraphQLSchema(config, errors, provider); return { graphQLSchema, diff --git a/packages/keystone/src/lib/server/createApolloServer.ts b/packages/keystone/src/lib/server/createApolloServer.ts index 70cce699e4d..adfd16257fc 100644 --- a/packages/keystone/src/lib/server/createApolloServer.ts +++ b/packages/keystone/src/lib/server/createApolloServer.ts @@ -3,20 +3,20 @@ import { GraphQLSchema } from 'graphql'; import { ApolloServer as ApolloServerMicro } from 'apollo-server-micro'; import { ApolloServer as ApolloServerExpress } from 'apollo-server-express'; import type { Config } from 'apollo-server-express'; -import type { CreateContext, SessionStrategy } from '@keystone-next/types'; +import type { CreateContext, GraphQLConfig, SessionStrategy } from '@keystone-next/types'; import { createSessionContext } from '../../session'; export const createApolloServerMicro = ({ graphQLSchema, createContext, sessionStrategy, - apolloConfig, + graphqlConfig, connectionPromise, }: { graphQLSchema: GraphQLSchema; createContext: CreateContext; sessionStrategy?: SessionStrategy; - apolloConfig?: Config; + graphqlConfig?: GraphQLConfig; connectionPromise: Promise; }) => { const context = async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => { @@ -28,7 +28,7 @@ export const createApolloServerMicro = ({ req, }); }; - const serverConfig = _createApolloServerConfig({ graphQLSchema, apolloConfig }); + const serverConfig = _createApolloServerConfig({ graphQLSchema, graphqlConfig }); return new ApolloServerMicro({ ...serverConfig, context }); }; @@ -36,12 +36,12 @@ export const createApolloServerExpress = ({ graphQLSchema, createContext, sessionStrategy, - apolloConfig, + graphqlConfig, }: { graphQLSchema: GraphQLSchema; createContext: CreateContext; sessionStrategy?: SessionStrategy; - apolloConfig?: Config; + graphqlConfig?: GraphQLConfig; }) => { const context = async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => createContext({ @@ -50,18 +50,19 @@ export const createApolloServerExpress = ({ : undefined, req, }); - const serverConfig = _createApolloServerConfig({ graphQLSchema, apolloConfig }); + const serverConfig = _createApolloServerConfig({ graphQLSchema, graphqlConfig }); return new ApolloServerExpress({ ...serverConfig, context }); }; const _createApolloServerConfig = ({ graphQLSchema, - apolloConfig, + graphqlConfig, }: { graphQLSchema: GraphQLSchema; - apolloConfig?: Config; + graphqlConfig?: GraphQLConfig; }) => { // Playground config, is /api/graphql available? + const apolloConfig = graphqlConfig?.apolloConfig; const pp = apolloConfig?.playground; let playground: Config['playground']; const settings = { 'request.credentials': 'same-origin' }; @@ -86,6 +87,7 @@ const _createApolloServerConfig = ({ return { uploads: false, schema: graphQLSchema, + debug: graphqlConfig?.debug, // If undefined, use Apollo default of NODE_ENV !== 'production' // FIXME: support for apollo studio tracing // ...(process.env.ENGINE_API_KEY || process.env.APOLLO_KEY // ? { tracing: true } diff --git a/packages/keystone/src/lib/server/createExpressServer.ts b/packages/keystone/src/lib/server/createExpressServer.ts index f4342538093..e94fe7c1297 100644 --- a/packages/keystone/src/lib/server/createExpressServer.ts +++ b/packages/keystone/src/lib/server/createExpressServer.ts @@ -1,9 +1,13 @@ -import type { Config } from 'apollo-server-express'; import cors, { CorsOptions } from 'cors'; import express from 'express'; import { GraphQLSchema } from 'graphql'; import { graphqlUploadExpress } from 'graphql-upload'; -import type { KeystoneConfig, CreateContext, SessionStrategy } from '@keystone-next/types'; +import type { + KeystoneConfig, + CreateContext, + SessionStrategy, + GraphQLConfig, +} from '@keystone-next/types'; import { createAdminUIServer } from '../../admin-ui/system'; import { createApolloServerExpress } from './createApolloServer'; import { addHealthCheck } from './addHealthCheck'; @@ -16,20 +20,20 @@ const addApolloServer = ({ graphQLSchema, createContext, sessionStrategy, - apolloConfig, + graphqlConfig, }: { server: express.Express; config: KeystoneConfig; graphQLSchema: GraphQLSchema; createContext: CreateContext; sessionStrategy?: SessionStrategy; - apolloConfig?: Config; + graphqlConfig?: GraphQLConfig; }) => { const apolloServer = createApolloServerExpress({ graphQLSchema, createContext, sessionStrategy, - apolloConfig, + graphqlConfig, }); const maxFileSize = config.server?.maxFileSize || DEFAULT_MAX_FILE_SIZE; @@ -68,7 +72,7 @@ export const createExpressServer = async ( graphQLSchema, createContext, sessionStrategy: config.session, - apolloConfig: config.graphql?.apolloConfig, + graphqlConfig: config.graphql, }); if (config.ui?.isDisabled) { diff --git a/packages/types/src/config/index.ts b/packages/types/src/config/index.ts index 0717d6c62aa..c7023c74d30 100644 --- a/packages/types/src/config/index.ts +++ b/packages/types/src/config/index.ts @@ -143,6 +143,40 @@ export type GraphQLConfig = { * @see https://www.apollographql.com/docs/apollo-server/api/apollo-server/#constructor */ apolloConfig?: Config; + /* + * When an error is returned from the GraphQL API, Apollo can include a stacktrace + * indicating where the error occurred. When Keystone is processing mutations, it + * will sometimes captures more than one error at a time, and then group these into + * a single error returned from the GraphQL API. Each of these errors will include + * a stacktrace. + * + * In general both categories of stacktrace are useful for debugging while developing, + * but should not be exposed in production, and this is the default behaviour of Keystone. + * + * You can use the `debug` option to change this behaviour. A use case for this + * would be if you need to send the stacktraces to a log, but do not want to return them + * from the API. In this case you could set `debug: true` and use + * `apolloConfig.formatError` to log the stacktraces and then strip them out before + * returning the error. + * + * ``` + * graphql: { + * debug: true, + * apolloConfig: { + * formatError: err => { + * console.error(err); + * delete err.extensions?.errors; + * delete err.extensions?.exception?.errors; + * delete err.extensions?.exception?.stacktrace; + * return err; + * }, + * }, + * } + * ``` + * * + * Default: process.env.NODE_ENV !== 'production' + */ + debug?: boolean; }; // config.extendGraphqlSchema diff --git a/tests/api-tests/hooks/hook-errors.test.ts b/tests/api-tests/hooks/hook-errors.test.ts index 387fdd71842..0615c72a059 100644 --- a/tests/api-tests/hooks/hook-errors.test.ts +++ b/tests/api-tests/hooks/hook-errors.test.ts @@ -3,554 +3,558 @@ import { createSchema, list } from '@keystone-next/keystone/schema'; import { setupTestRunner } from '@keystone-next/testing'; import { apiTestConfig, expectAccessDenied, expectExtensionError } from '../utils'; -const runner = setupTestRunner({ - config: apiTestConfig({ - lists: createSchema({ - User: list({ - fields: { name: text() }, - hooks: { - beforeChange: ({ resolvedData }) => { - if (resolvedData.name === 'trigger before') { - throw new Error('Simulated error: beforeChange'); - } - }, - afterChange: ({ resolvedData }) => { - if (resolvedData.name === 'trigger after') { - throw new Error('Simulated error: afterChange'); - } - }, - beforeDelete: ({ existingItem }) => { - if (existingItem.name === 'trigger before delete') { - throw new Error('Simulated error: beforeDelete'); - } - }, - afterDelete: ({ existingItem }) => { - if (existingItem.name === 'trigger after delete') { - throw new Error('Simulated error: afterDelete'); - } - }, - }, - }), - Post: list({ - fields: { - title: text({ - hooks: { - beforeChange: ({ resolvedData }) => { - if (resolvedData.title === 'trigger before') { - throw new Error('Simulated error: title: beforeChange'); - } - }, - afterChange: ({ resolvedData }) => { - if (resolvedData.title === 'trigger after') { - throw new Error('Simulated error: title: afterChange'); - } - }, - beforeDelete: ({ existingItem }) => { - if (existingItem.title === 'trigger before delete') { - throw new Error('Simulated error: title: beforeDelete'); - } - }, - afterDelete: ({ existingItem }) => { - if (existingItem.title === 'trigger after delete') { - throw new Error('Simulated error: title: afterDelete'); - } - }, +const runner = (debug: boolean | undefined) => + setupTestRunner({ + config: apiTestConfig({ + lists: createSchema({ + User: list({ + fields: { name: text() }, + hooks: { + beforeChange: ({ resolvedData }) => { + if (resolvedData.name === 'trigger before') { + throw new Error('Simulated error: beforeChange'); + } }, - }), - content: text({ - hooks: { - beforeChange: ({ resolvedData }) => { - if (resolvedData.content === 'trigger before') { - throw new Error('Simulated error: content: beforeChange'); - } - }, - afterChange: ({ resolvedData }) => { - if (resolvedData.content === 'trigger after') { - throw new Error('Simulated error: content: afterChange'); - } - }, - beforeDelete: ({ existingItem }) => { - if (existingItem.content === 'trigger before delete') { - throw new Error('Simulated error: content: beforeDelete'); - } + afterChange: ({ resolvedData }) => { + if (resolvedData.name === 'trigger after') { + throw new Error('Simulated error: afterChange'); + } + }, + beforeDelete: ({ existingItem }) => { + if (existingItem.name === 'trigger before delete') { + throw new Error('Simulated error: beforeDelete'); + } + }, + afterDelete: ({ existingItem }) => { + if (existingItem.name === 'trigger after delete') { + throw new Error('Simulated error: afterDelete'); + } + }, + }, + }), + Post: list({ + fields: { + title: text({ + hooks: { + beforeChange: ({ resolvedData }) => { + if (resolvedData.title === 'trigger before') { + throw new Error('Simulated error: title: beforeChange'); + } + }, + afterChange: ({ resolvedData }) => { + if (resolvedData.title === 'trigger after') { + throw new Error('Simulated error: title: afterChange'); + } + }, + beforeDelete: ({ existingItem }) => { + if (existingItem.title === 'trigger before delete') { + throw new Error('Simulated error: title: beforeDelete'); + } + }, + afterDelete: ({ existingItem }) => { + if (existingItem.title === 'trigger after delete') { + throw new Error('Simulated error: title: afterDelete'); + } + }, }, - afterDelete: ({ existingItem }) => { - if (existingItem.content === 'trigger after delete') { - throw new Error('Simulated error: content: afterDelete'); - } + }), + content: text({ + hooks: { + beforeChange: ({ resolvedData }) => { + if (resolvedData.content === 'trigger before') { + throw new Error('Simulated error: content: beforeChange'); + } + }, + afterChange: ({ resolvedData }) => { + if (resolvedData.content === 'trigger after') { + throw new Error('Simulated error: content: afterChange'); + } + }, + beforeDelete: ({ existingItem }) => { + if (existingItem.content === 'trigger before delete') { + throw new Error('Simulated error: content: beforeDelete'); + } + }, + afterDelete: ({ existingItem }) => { + if (existingItem.content === 'trigger after delete') { + throw new Error('Simulated error: content: afterDelete'); + } + }, }, - }, - }), - }, + }), + }, + }), }), + graphql: { debug }, }), - }), -}); + }); + +[true, false, undefined].map(debug => { + (['dev', 'production'] as const).map(mode => + describe(`NODE_ENV=${mode}, debug=${debug}`, () => { + beforeAll(() => { + // @ts-ignore + process.env.NODE_ENV = mode; + }); + afterAll(() => { + // @ts-ignore + process.env.NODE_ENV = 'test'; + }); -(['dev', 'production'] as const).map(mode => - describe(`NODE_ENV=${mode}`, () => { - beforeAll(() => { - // @ts-ignore - process.env.NODE_ENV = mode; - }); - afterAll(() => { - // @ts-ignore - process.env.NODE_ENV = 'test'; - }); + ['before', 'after'].map(phase => { + describe(`List Hooks: ${phase}Change/${phase}Delete()`, () => { + test( + 'createOne', + runner(debug)(async ({ context }) => { + // Valid name should pass + await context.lists.User.createOne({ data: { name: 'good' } }); - ['before', 'after'].map(phase => { - describe(`List Hooks: ${phase}Change/${phase}Delete()`, () => { - test( - 'createOne', - runner(async ({ context }) => { - // Valid name should pass - await context.lists.User.createOne({ data: { name: 'good' } }); + // Trigger an error + const { data, errors } = await context.graphql.raw({ + query: `mutation ($data: UserCreateInput!) { createUser(data: $data) { id } }`, + variables: { data: { name: `trigger ${phase}` } }, + }); - // Trigger an error - const { data, errors } = await context.graphql.raw({ - query: `mutation ($data: UserCreateInput!) { createUser(data: $data) { id } }`, - variables: { data: { name: `trigger ${phase}` } }, - }); + // Returns null and throws an error + expect(data).toEqual({ createUser: null }); + const message = `Simulated error: ${phase}Change`; + expectExtensionError(mode, false, debug, errors, `${phase}Change`, [ + { + path: ['createUser'], + messages: [`User: Simulated error: ${phase}Change`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + ]); - // Returns null and throws an error - expect(data).toEqual({ createUser: null }); - const message = `Simulated error: ${phase}Change`; - expectExtensionError(mode, false, errors, `${phase}Change`, [ - { - path: ['createUser'], - messages: [`User: Simulated error: ${phase}Change`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - ], - }, - ]); + // Only the original user should exist for 'before', both exist for 'after' + const _users = await context.lists.User.findMany({ query: 'id name' }); + expect(_users.map(({ name }) => name)).toEqual( + phase === 'before' ? ['good'] : ['good', 'trigger after'] + ); + }) + ); - // Only the original user should exist for 'before', both exist for 'after' - const _users = await context.lists.User.findMany({ query: 'id name' }); - expect(_users.map(({ name }) => name)).toEqual( - phase === 'before' ? ['good'] : ['good', 'trigger after'] - ); - }) - ); + test( + 'updateOne', + runner(debug)(async ({ context }) => { + // Valid name should pass + const user = await context.lists.User.createOne({ data: { name: 'good' } }); + await context.lists.User.updateOne({ + where: { id: user.id }, + data: { name: 'better' }, + }); - test( - 'updateOne', - runner(async ({ context }) => { - // Valid name should pass - const user = await context.lists.User.createOne({ data: { name: 'good' } }); - await context.lists.User.updateOne({ - where: { id: user.id }, - data: { name: 'better' }, - }); + // Invalid name + const { data, errors } = await context.graphql.raw({ + query: `mutation ($id: ID! $data: UserUpdateInput!) { updateUser(where: { id: $id }, data: $data) { id } }`, + variables: { id: user.id, data: { name: `trigger ${phase}` } }, + }); - // Invalid name - const { data, errors } = await context.graphql.raw({ - query: `mutation ($id: ID! $data: UserUpdateInput!) { updateUser(where: { id: $id }, data: $data) { id } }`, - variables: { id: user.id, data: { name: `trigger ${phase}` } }, - }); + // Returns null and throws an error + expect(data).toEqual({ updateUser: null }); + const message = `Simulated error: ${phase}Change`; + expectExtensionError(mode, false, debug, errors, `${phase}Change`, [ + { + path: ['updateUser'], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + ]); - // Returns null and throws an error - expect(data).toEqual({ updateUser: null }); - const message = `Simulated error: ${phase}Change`; - expectExtensionError(mode, false, errors, `${phase}Change`, [ - { - path: ['updateUser'], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - ], - }, - ]); + // User should have its original name for 'before', and the new name for 'after'. + const _users = await context.lists.User.findMany({ query: 'id name' }); + expect(_users.map(({ name }) => name)).toEqual( + phase === 'before' ? ['better'] : ['trigger after'] + ); + }) + ); - // User should have its original name for 'before', and the new name for 'after'. - const _users = await context.lists.User.findMany({ query: 'id name' }); - expect(_users.map(({ name }) => name)).toEqual( - phase === 'before' ? ['better'] : ['trigger after'] - ); - }) - ); + test( + 'deleteOne', + runner(debug)(async ({ context }) => { + // Valid names should pass + const user1 = await context.lists.User.createOne({ data: { name: 'good' } }); + const user2 = await context.lists.User.createOne({ + data: { name: `trigger ${phase} delete` }, + }); + await context.lists.User.deleteOne({ where: { id: user1.id } }); - test( - 'deleteOne', - runner(async ({ context }) => { - // Valid names should pass - const user1 = await context.lists.User.createOne({ data: { name: 'good' } }); - const user2 = await context.lists.User.createOne({ - data: { name: `trigger ${phase} delete` }, - }); - await context.lists.User.deleteOne({ where: { id: user1.id } }); + // Invalid name + const { data, errors } = await context.graphql.raw({ + query: `mutation ($id: ID!) { deleteUser(where: { id: $id }) { id } }`, + variables: { id: user2.id }, + }); + + // Returns null and throws an error + expect(data).toEqual({ deleteUser: null }); + const message = `Simulated error: ${phase}Delete`; + expectExtensionError(mode, false, debug, errors, `${phase}Delete`, [ + { + path: ['deleteUser'], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Delete .${__filename}`) + ), + }, + ], + }, + ]); - // Invalid name - const { data, errors } = await context.graphql.raw({ - query: `mutation ($id: ID!) { deleteUser(where: { id: $id }) { id } }`, - variables: { id: user2.id }, - }); + // Bad users should still be in the database for 'before', deleted for 'after'. + const _users = await context.lists.User.findMany({ query: 'id name' }); + expect(_users.map(({ name }) => name)).toEqual( + phase === 'before' ? ['trigger before delete'] : [] + ); + }) + ); - // Returns null and throws an error - expect(data).toEqual({ deleteUser: null }); - const message = `Simulated error: ${phase}Delete`; - expectExtensionError(mode, false, errors, `${phase}Delete`, [ - { - path: ['deleteUser'], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Delete .${__filename}`) - ), - }, + test( + 'createMany', + runner(debug)(async ({ context }) => { + // Mix of good and bad names + const { data, errors } = await context.graphql.raw({ + query: `mutation ($data: [UserCreateInput!]!) { createUsers(data: $data) { id name } }`, + variables: { + data: [ + { name: 'good 1' }, + { name: `trigger ${phase}` }, + { name: 'good 2' }, + { name: `trigger ${phase}` }, + { name: 'good 3' }, + ], + }, + }); + + // Valid users are returned, invalid come back as null + expect(data).toEqual({ + createUsers: [ + { id: expect.any(String), name: 'good 1' }, + null, + { id: expect.any(String), name: 'good 2' }, + null, + { id: expect.any(String), name: 'good 3' }, ], - }, - ]); + }); + // The invalid creates should have errors which point to the nulls in their path + const message = `Simulated error: ${phase}Change`; + expectExtensionError(mode, false, debug, errors, `${phase}Change`, [ + { + path: ['createUsers', 1], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + { + path: ['createUsers', 3], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + ]); - // Bad users should still be in the database for 'before', deleted for 'after'. - const _users = await context.lists.User.findMany({ query: 'id name' }); - expect(_users.map(({ name }) => name)).toEqual( - phase === 'before' ? ['trigger before delete'] : [] - ); - }) - ); + // Three users should exist in the database for 'before,' five for 'after'. + const users = await context.lists.User.findMany({ + orderBy: { name: 'asc' }, + query: 'id name', + }); + expect(users.map(({ name }) => name)).toEqual( + phase === 'before' + ? ['good 1', 'good 2', 'good 3'] + : ['good 1', 'good 2', 'good 3', 'trigger after', 'trigger after'] + ); + }) + ); - test( - 'createMany', - runner(async ({ context }) => { - // Mix of good and bad names - const { data, errors } = await context.graphql.raw({ - query: `mutation ($data: [UserCreateInput!]!) { createUsers(data: $data) { id name } }`, - variables: { + test( + 'updateMany', + runner(debug)(async ({ context }) => { + // Start with some users + const users = await context.lists.User.createMany({ data: [ { name: 'good 1' }, - { name: `trigger ${phase}` }, { name: 'good 2' }, - { name: `trigger ${phase}` }, { name: 'good 3' }, + { name: 'good 4' }, + { name: 'good 5' }, ], - }, - }); + query: 'id name', + }); - // Valid users are returned, invalid come back as null - expect(data).toEqual({ - createUsers: [ - { id: expect.any(String), name: 'good 1' }, - null, - { id: expect.any(String), name: 'good 2' }, - null, - { id: expect.any(String), name: 'good 3' }, - ], - }); - // The invalid creates should have errors which point to the nulls in their path - const message = `Simulated error: ${phase}Change`; - expectExtensionError(mode, false, errors, `${phase}Change`, [ - { - path: ['createUsers', 1], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - ], - }, - { - path: ['createUsers', 3], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - ], - }, - ]); + // Mix of good and bad names + const { data, errors } = await context.graphql.raw({ + query: `mutation ($data: [UserUpdateArgs!]!) { updateUsers(data: $data) { id name } }`, + variables: { + data: [ + { where: { id: users[0].id }, data: { name: 'still good 1' } }, + { where: { id: users[1].id }, data: { name: `trigger ${phase}` } }, + { where: { id: users[2].id }, data: { name: 'still good 3' } }, + { where: { id: users[3].id }, data: { name: `trigger ${phase}` } }, + ], + }, + }); - // Three users should exist in the database for 'before,' five for 'after'. - const users = await context.lists.User.findMany({ - orderBy: { name: 'asc' }, - query: 'id name', - }); - expect(users.map(({ name }) => name)).toEqual( - phase === 'before' - ? ['good 1', 'good 2', 'good 3'] - : ['good 1', 'good 2', 'good 3', 'trigger after', 'trigger after'] - ); - }) - ); + // Valid users are returned, invalid come back as null + expect(data).toEqual({ + updateUsers: [ + { id: users[0].id, name: 'still good 1' }, + null, + { id: users[2].id, name: 'still good 3' }, + null, + ], + }); + // The invalid updates should have errors which point to the nulls in their path + const message = `Simulated error: ${phase}Change`; + expectExtensionError(mode, false, debug, errors, `${phase}Change`, [ + { + path: ['updateUsers', 1], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + { + path: ['updateUsers', 3], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + ]); - test( - 'updateMany', - runner(async ({ context }) => { - // Start with some users - const users = await context.lists.User.createMany({ - data: [ - { name: 'good 1' }, - { name: 'good 2' }, - { name: 'good 3' }, - { name: 'good 4' }, - { name: 'good 5' }, - ], - query: 'id name', - }); + // All users should still exist in the database, un-changed for `before`, changed for `after`. + const _users = await context.lists.User.findMany({ + orderBy: { name: 'asc' }, + query: 'id name', + }); + expect(_users.map(({ name }) => name)).toEqual( + phase === 'before' + ? ['good 2', 'good 4', 'good 5', 'still good 1', 'still good 3'] + : ['good 5', 'still good 1', 'still good 3', 'trigger after', 'trigger after'] + ); + }) + ); - // Mix of good and bad names - const { data, errors } = await context.graphql.raw({ - query: `mutation ($data: [UserUpdateArgs!]!) { updateUsers(data: $data) { id name } }`, - variables: { + test( + 'deleteMany', + runner(debug)(async ({ context }) => { + // Start with some users + const users = await context.lists.User.createMany({ data: [ - { where: { id: users[0].id }, data: { name: 'still good 1' } }, - { where: { id: users[1].id }, data: { name: `trigger ${phase}` } }, - { where: { id: users[2].id }, data: { name: 'still good 3' } }, - { where: { id: users[3].id }, data: { name: `trigger ${phase}` } }, - ], - }, - }); - - // Valid users are returned, invalid come back as null - expect(data).toEqual({ - updateUsers: [ - { id: users[0].id, name: 'still good 1' }, - null, - { id: users[2].id, name: 'still good 3' }, - null, - ], - }); - // The invalid updates should have errors which point to the nulls in their path - const message = `Simulated error: ${phase}Change`; - expectExtensionError(mode, false, errors, `${phase}Change`, [ - { - path: ['updateUsers', 1], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - ], - }, - { - path: ['updateUsers', 3], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Change .${__filename}`) - ), - }, + { name: 'good 1' }, + { name: `trigger ${phase} delete` }, + { name: 'good 3' }, + { name: `trigger ${phase} delete` }, + { name: 'good 5' }, ], - }, - ]); - - // All users should still exist in the database, un-changed for `before`, changed for `after`. - const _users = await context.lists.User.findMany({ - orderBy: { name: 'asc' }, - query: 'id name', - }); - expect(_users.map(({ name }) => name)).toEqual( - phase === 'before' - ? ['good 2', 'good 4', 'good 5', 'still good 1', 'still good 3'] - : ['good 5', 'still good 1', 'still good 3', 'trigger after', 'trigger after'] - ); - }) - ); - - test( - 'deleteMany', - runner(async ({ context }) => { - // Start with some users - const users = await context.lists.User.createMany({ - data: [ - { name: 'good 1' }, - { name: `trigger ${phase} delete` }, - { name: 'good 3' }, - { name: `trigger ${phase} delete` }, - { name: 'good 5' }, - ], - query: 'id name', - }); + query: 'id name', + }); - // Mix of good and bad names - const { data, errors } = await context.graphql.raw({ - query: `mutation ($where: [UserWhereUniqueInput!]!) { deleteUsers(where: $where) { id name } }`, - variables: { - where: [users[0].id, users[1].id, users[2].id, users[3].id].map(id => ({ id })), - }, - }); + // Mix of good and bad names + const { data, errors } = await context.graphql.raw({ + query: `mutation ($where: [UserWhereUniqueInput!]!) { deleteUsers(where: $where) { id name } }`, + variables: { + where: [users[0].id, users[1].id, users[2].id, users[3].id].map(id => ({ id })), + }, + }); - // Valid users are returned, invalid come back as null - expect(data).toEqual({ - deleteUsers: [ - { id: users[0].id, name: 'good 1' }, - null, - { id: users[2].id, name: 'good 3' }, - null, - ], - }); - // The invalid deletes should have errors which point to the nulls in their path - const message = `Simulated error: ${phase}Delete`; - expectExtensionError(mode, false, errors, `${phase}Delete`, [ - { - path: ['deleteUsers', 1], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Delete .${__filename}`) - ), - }, + // Valid users are returned, invalid come back as null + expect(data).toEqual({ + deleteUsers: [ + { id: users[0].id, name: 'good 1' }, + null, + { id: users[2].id, name: 'good 3' }, + null, ], - }, - { - path: ['deleteUsers', 3], - messages: [`User: ${message}`], - errors: [ - { - message, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message}\n[^\n]*${phase}Delete .${__filename}`) - ), - }, - ], - }, - ]); + }); + // The invalid deletes should have errors which point to the nulls in their path + const message = `Simulated error: ${phase}Delete`; + expectExtensionError(mode, false, debug, errors, `${phase}Delete`, [ + { + path: ['deleteUsers', 1], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Delete .${__filename}`) + ), + }, + ], + }, + { + path: ['deleteUsers', 3], + messages: [`User: ${message}`], + debug: [ + { + message, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message}\n[^\n]*${phase}Delete .${__filename}`) + ), + }, + ], + }, + ]); - // Three users should still exist in the database for `before`, only 1 for `after`. - const _users = await context.lists.User.findMany({ - orderBy: { name: 'asc' }, - query: 'id name', - }); - expect(_users.map(({ name }) => name)).toEqual( - phase === 'before' - ? ['good 5', 'trigger before delete', 'trigger before delete'] - : ['good 5'] - ); - }) - ); + // Three users should still exist in the database for `before`, only 1 for `after`. + const _users = await context.lists.User.findMany({ + orderBy: { name: 'asc' }, + query: 'id name', + }); + expect(_users.map(({ name }) => name)).toEqual( + phase === 'before' + ? ['good 5', 'trigger before delete', 'trigger before delete'] + : ['good 5'] + ); + }) + ); + }); }); - }); - ['before', 'after'].map(phase => { - describe(`Field Hooks: ${phase}Change/${phase}Delete()`, () => { - test( - 'update', - runner(async ({ context }) => { - const post = await context.lists.Post.createOne({ - data: { title: 'original title', content: 'original content' }, - }); + ['before', 'after'].map(phase => { + describe(`Field Hooks: ${phase}Change/${phase}Delete()`, () => { + test( + 'update', + runner(debug)(async ({ context }) => { + const post = await context.lists.Post.createOne({ + data: { title: 'original title', content: 'original content' }, + }); - const { data, errors } = await context.graphql.raw({ - query: `mutation ($id: ID! $data: PostUpdateInput!) { updatePost(where: { id: $id }, data: $data) { id } }`, - variables: { - id: post.id, - data: { title: `trigger ${phase}`, content: `trigger ${phase}` }, - }, - }); - const message1 = `Simulated error: title: ${phase}Change`; - const message2 = `Simulated error: content: ${phase}Change`; - expectExtensionError(mode, false, errors, `${phase}Change`, [ - { - path: ['updatePost'], - messages: [`Post.title: ${message1}`, `Post.content: ${message2}`], - errors: [ - { - message: message1, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message1}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - { - message: message2, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message2}\n[^\n]*${phase}Change .${__filename}`) - ), - }, - ], - }, - ]); - expect(data).toEqual({ updatePost: null }); + const { data, errors } = await context.graphql.raw({ + query: `mutation ($id: ID! $data: PostUpdateInput!) { updatePost(where: { id: $id }, data: $data) { id } }`, + variables: { + id: post.id, + data: { title: `trigger ${phase}`, content: `trigger ${phase}` }, + }, + }); + const message1 = `Simulated error: title: ${phase}Change`; + const message2 = `Simulated error: content: ${phase}Change`; + expectExtensionError(mode, false, debug, errors, `${phase}Change`, [ + { + path: ['updatePost'], + messages: [`Post.title: ${message1}`, `Post.content: ${message2}`], + debug: [ + { + message: message1, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message1}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + { + message: message2, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message2}\n[^\n]*${phase}Change .${__filename}`) + ), + }, + ], + }, + ]); + expect(data).toEqual({ updatePost: null }); - // Post should have its original data for 'before', and the new data for 'after'. - const _post = await context.lists.Post.findOne({ - where: { id: post.id }, - query: 'title content', - }); - expect(_post).toEqual( - phase === 'before' - ? { title: 'original title', content: 'original content' } - : { title: 'trigger after', content: 'trigger after' } - ); - }) - ); + // Post should have its original data for 'before', and the new data for 'after'. + const _post = await context.lists.Post.findOne({ + where: { id: post.id }, + query: 'title content', + }); + expect(_post).toEqual( + phase === 'before' + ? { title: 'original title', content: 'original content' } + : { title: 'trigger after', content: 'trigger after' } + ); + }) + ); - test( - `delete`, - runner(async ({ context, graphQLRequest }) => { - const post = await context.lists.Post.createOne({ - data: { title: `trigger ${phase} delete`, content: `trigger ${phase} delete` }, - }); - const { body } = await graphQLRequest({ - query: `mutation ($id: ID!) { deletePost(where: { id: $id }) { id } }`, - variables: { id: post.id }, - }); - const { data, errors } = body; - const message1 = `Simulated error: title: ${phase}Delete`; - const message2 = `Simulated error: content: ${phase}Delete`; - expectExtensionError(mode, true, errors, `${phase}Delete`, [ - { - path: ['deletePost'], - messages: [`Post.title: ${message1}`, `Post.content: ${message2}`], - errors: [ - { - message: message1, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message1}\n[^\n]*${phase}Delete .${__filename}`) - ), - }, - { - message: message2, - stacktrace: expect.stringMatching( - new RegExp(`Error: ${message2}\n[^\n]*${phase}Delete .${__filename}`) - ), - }, - ], - }, - ]); - expect(data).toEqual({ deletePost: null }); + test( + `delete`, + runner(debug)(async ({ context, graphQLRequest }) => { + const post = await context.lists.Post.createOne({ + data: { title: `trigger ${phase} delete`, content: `trigger ${phase} delete` }, + }); + const { body } = await graphQLRequest({ + query: `mutation ($id: ID!) { deletePost(where: { id: $id }) { id } }`, + variables: { id: post.id }, + }); + const { data, errors } = body; + const message1 = `Simulated error: title: ${phase}Delete`; + const message2 = `Simulated error: content: ${phase}Delete`; + expectExtensionError(mode, true, debug, errors, `${phase}Delete`, [ + { + path: ['deletePost'], + messages: [`Post.title: ${message1}`, `Post.content: ${message2}`], + debug: [ + { + message: message1, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message1}\n[^\n]*${phase}Delete .${__filename}`) + ), + }, + { + message: message2, + stacktrace: expect.stringMatching( + new RegExp(`Error: ${message2}\n[^\n]*${phase}Delete .${__filename}`) + ), + }, + ], + }, + ]); + expect(data).toEqual({ deletePost: null }); - // Post should have its original data for 'before', and not exist for 'after'. - const result = await context.graphql.raw({ - query: `query ($id: ID!) { post(where: { id: $id }) { title content} }`, - variables: { id: post.id }, - }); - if (phase === 'before') { - expect(result.errors).toBe(undefined); - expect(result.data).toEqual({ - post: { title: 'trigger before delete', content: 'trigger before delete' }, + // Post should have its original data for 'before', and not exist for 'after'. + const result = await context.graphql.raw({ + query: `query ($id: ID!) { post(where: { id: $id }) { title content} }`, + variables: { id: post.id }, }); - } else { - expectAccessDenied(result.errors, [{ path: ['post'] }]); - expect(result.data).toEqual({ post: null }); - } - }) - ); + if (phase === 'before') { + expect(result.errors).toBe(undefined); + expect(result.data).toEqual({ + post: { title: 'trigger before delete', content: 'trigger before delete' }, + }); + } else { + expectAccessDenied(result.errors, [{ path: ['post'] }]); + expect(result.data).toEqual({ post: null }); + } + }) + ); + }); }); - }); - }) -); + }) + ); +}); diff --git a/tests/api-tests/utils.ts b/tests/api-tests/utils.ts index cdda1f790bf..4a87f542a63 100644 --- a/tests/api-tests/utils.ts +++ b/tests/api-tests/utils.ts @@ -89,23 +89,33 @@ export const expectValidationError = ( export const expectExtensionError = ( mode: 'dev' | 'production', httpQuery: boolean, + _debug: boolean | undefined, errors: readonly any[] | undefined, extensionName: string, - args: { path: (string | number)[]; messages: string[]; errors: any[] }[] + args: { path: (string | number)[]; messages: string[]; debug: any[] }[] ) => { const unpackedErrors = unpackErrors(errors); expect(unpackedErrors).toEqual( - args.map(({ path, messages, errors }) => { + args.map(({ path, messages, debug }) => { const message = `An error occured while running "${extensionName}".\n${j(messages)}`; const stacktrace = message.split('\n'); stacktrace[0] = `Error: ${stacktrace[0]}`; + + // We expect to see error details if: + // - graphql.debug is true or + // - graphql.debug is undefined and mode !== production + const expectErrors = _debug === true || (_debug === undefined && mode !== 'production'); + // We expect to see the Apollo exception under the same conditions, but only if + // httpQuery is also true. + const expectException = httpQuery && expectErrors; + return { extensions: { code: 'INTERNAL_SERVER_ERROR', - ...(httpQuery && mode !== 'production' - ? { exception: { errors, stacktrace: expect.arrayContaining(stacktrace) } } + ...(expectException + ? { exception: { debug, stacktrace: expect.arrayContaining(stacktrace) } } : {}), - ...(mode !== 'production' ? { errors } : {}), + ...(expectErrors ? { debug } : {}), }, path, message,