diff --git a/.changeset/tall-shoes-serve.md b/.changeset/tall-shoes-serve.md new file mode 100644 index 00000000000..6aa5d9b8fac --- /dev/null +++ b/.changeset/tall-shoes-serve.md @@ -0,0 +1,18 @@ +--- +'@apollo/server': minor +--- + +Allow formatError to be an `async` function. + +``` +const server = new ApolloServer({ + typeDefs, + resolvers, + formatError: async () => { + return Promise.resolve({ + code: 'MY_ERROR', + message: 'This is an error that was updated by formatError', + }); + }, +}); +``` diff --git a/packages/server/src/ApolloServer.ts b/packages/server/src/ApolloServer.ts index 4a9c8d97c9f..3619301d0e0 100644 --- a/packages/server/src/ApolloServer.ts +++ b/packages/server/src/ApolloServer.ts @@ -159,7 +159,7 @@ export interface ApolloServerInternals { formatError?: ( formattedError: GraphQLFormattedError, error: unknown, - ) => GraphQLFormattedError; + ) => GraphQLFormattedError | Promise; includeStacktraceInErrorResponses: boolean; persistedQueries?: WithRequired; nodeEnv: string; @@ -1136,7 +1136,7 @@ export class ApolloServer { error: unknown, requestHead: HTTPGraphQLHead, ): Promise { - const { formattedErrors, httpFromErrors } = normalizeAndFormatErrors( + const { formattedErrors, httpFromErrors } = await normalizeAndFormatErrors( [error], { includeStacktraceInErrorResponses: diff --git a/packages/server/src/__tests__/ApolloServer.test.ts b/packages/server/src/__tests__/ApolloServer.test.ts index 460a7d5cc76..49b01b9344b 100644 --- a/packages/server/src/__tests__/ApolloServer.test.ts +++ b/packages/server/src/__tests__/ApolloServer.test.ts @@ -475,6 +475,60 @@ describe('ApolloServer executeOperation', () => { await server.stop(); }); + it('should format errors when provided a formatErrors function', async () => { + const server = new ApolloServer({ + typeDefs, + resolvers, + formatError: () => { + return { + code: 'MY_ERROR', + message: 'This is an error that was updated by formatError', + }; + }, + }); + await server.start(); + + const { body } = await server.executeOperation({ + query: 'query { error }', + }); + + const result = singleResult(body); + expect(result.errors).toHaveLength(1); + expect(result.errors?.[0]).toHaveProperty('code', 'MY_ERROR'); + expect(result.errors?.[0]).toHaveProperty( + 'message', + 'This is an error that was updated by formatError', + ); + await server.stop(); + }); + + it('should format errors when provided an async formatErrors function', async () => { + const server = new ApolloServer({ + typeDefs, + resolvers, + formatError: async () => { + return Promise.resolve({ + code: 'MY_ERROR', + message: 'This is an error that was updated by formatError', + }); + }, + }); + await server.start(); + + const { body } = await server.executeOperation({ + query: 'query { error }', + }); + + const result = singleResult(body); + expect(result.errors).toHaveLength(1); + expect(result.errors?.[0]).toHaveProperty('code', 'MY_ERROR'); + expect(result.errors?.[0]).toHaveProperty( + 'message', + 'This is an error that was updated by formatError', + ); + await server.stop(); + }); + it('works with string', async () => { const server = new ApolloServer({ typeDefs, diff --git a/packages/server/src/__tests__/errors.test.ts b/packages/server/src/__tests__/errors.test.ts index 5b136e4b527..270fbc937c4 100644 --- a/packages/server/src/__tests__/errors.test.ts +++ b/packages/server/src/__tests__/errors.test.ts @@ -10,17 +10,19 @@ describe('Errors', () => { const code = 'CODE'; const key = 'value'; - it('exposes a stacktrace in debug mode', () => { + it('exposes a stacktrace in debug mode', async () => { const thrown = new Error(message); (thrown as any).key = key; - const [error] = normalizeAndFormatErrors( - [ - new GraphQLError(thrown.message, { - originalError: thrown, - extensions: { code, key }, - }), - ], - { includeStacktraceInErrorResponses: true }, + const [error] = ( + await normalizeAndFormatErrors( + [ + new GraphQLError(thrown.message, { + originalError: thrown, + extensions: { code, key }, + }), + ], + { includeStacktraceInErrorResponses: true }, + ) ).formattedErrors; expect(error.message).toEqual(message); expect(error.extensions?.key).toEqual(key); @@ -29,29 +31,36 @@ describe('Errors', () => { // stacktrace should exist expect(error.extensions?.stacktrace).toBeDefined(); }); - it('hides stacktrace by default', () => { + + it('hides stacktrace by default', async () => { const thrown = new Error(message); (thrown as any).key = key; - const error = normalizeAndFormatErrors([ - new GraphQLError(thrown.message, { originalError: thrown }), - ]).formattedErrors[0]; + const error = ( + await normalizeAndFormatErrors([ + new GraphQLError(thrown.message, { originalError: thrown }), + ]) + ).formattedErrors[0]; expect(error.message).toEqual(message); expect(error.extensions?.code).toEqual('INTERNAL_SERVER_ERROR'); expect(error.extensions).not.toHaveProperty('exception'); // Removed in AS4 // stacktrace should not exist expect(error.extensions).not.toHaveProperty('stacktrace'); }); - it('exposes extensions on error as extensions field and provides code', () => { - const error = normalizeAndFormatErrors([ - new GraphQLError(message, { - extensions: { code, key }, - }), - ]).formattedErrors[0]; + + it('exposes extensions on error as extensions field and provides code', async () => { + const error = ( + await normalizeAndFormatErrors([ + new GraphQLError(message, { + extensions: { code, key }, + }), + ]) + ).formattedErrors[0]; expect(error.message).toEqual(message); expect(error.extensions?.key).toEqual(key); expect(error.extensions).not.toHaveProperty('exception'); // Removed in AS4 expect(error.extensions?.code).toEqual(code); }); + it('calls formatError after exposing the code and stacktrace', () => { const error = new GraphQLError(message, { extensions: { code, key }, @@ -72,11 +81,11 @@ describe('Errors', () => { expect(formatErrorArgs[0].extensions?.code).toEqual(code); expect(formatErrorArgs[1]).toEqual(error); }); - it('Formats native Errors in a JSON-compatible way', () => { + + it('Formats native Errors in a JSON-compatible way', async () => { const error = new Error('Hello'); - const [formattedError] = normalizeAndFormatErrors([ - error, - ]).formattedErrors; + const [formattedError] = (await normalizeAndFormatErrors([error])) + .formattedErrors; expect(JSON.parse(JSON.stringify(formattedError)).message).toBe('Hello'); }); @@ -105,13 +114,15 @@ describe('Errors', () => { }; } - it('with stack trace', () => { + it('with stack trace', async () => { const thrown = new Error(message); (thrown as any).key = key; - const errors = normalizeAndFormatErrors([thrown], { - formatError, - includeStacktraceInErrorResponses: true, - }).formattedErrors; + const errors = ( + await normalizeAndFormatErrors([thrown], { + formatError, + includeStacktraceInErrorResponses: true, + }) + ).formattedErrors; expect(errors).toHaveLength(1); const [error] = errors; expect(error.extensions?.exception).toHaveProperty('stacktrace'); @@ -129,13 +140,15 @@ describe('Errors', () => { `); }); - it('without stack trace', () => { + it('without stack trace', async () => { const thrown = new Error(message); (thrown as any).key = key; - const errors = normalizeAndFormatErrors([thrown], { - formatError, - includeStacktraceInErrorResponses: false, - }).formattedErrors; + const errors = ( + await normalizeAndFormatErrors([thrown], { + formatError, + includeStacktraceInErrorResponses: false, + }) + ).formattedErrors; expect(errors).toHaveLength(1); const [error] = errors; expect(error).toMatchInlineSnapshot(` diff --git a/packages/server/src/errorNormalize.ts b/packages/server/src/errorNormalize.ts index 55efdde1ef9..6db6c46520d 100644 --- a/packages/server/src/errorNormalize.ts +++ b/packages/server/src/errorNormalize.ts @@ -18,41 +18,43 @@ import { HeaderMap } from './utils/HeaderMap.js'; // are removed from the formatted error. // // This function should not throw. -export function normalizeAndFormatErrors( +export async function normalizeAndFormatErrors( errors: ReadonlyArray, options: { formatError?: ( formattedError: GraphQLFormattedError, error: unknown, - ) => GraphQLFormattedError; + ) => GraphQLFormattedError | Promise; includeStacktraceInErrorResponses?: boolean; } = {}, -): { +): Promise<{ formattedErrors: Array; httpFromErrors: HTTPGraphQLHead; -} { +}> { const formatError = options.formatError ?? ((error) => error); const httpFromErrors = newHTTPGraphQLHead(); return { httpFromErrors, - formattedErrors: errors.map((error) => { - try { - return formatError(enrichError(error), error); - } catch (formattingError) { - if (options.includeStacktraceInErrorResponses) { - // includeStacktraceInErrorResponses is used in development - // so it will be helpful to show errors thrown by formatError hooks in that mode - return enrichError(formattingError); - } else { - // obscure error - return { - message: 'Internal server error', - extensions: { code: ApolloServerErrorCode.INTERNAL_SERVER_ERROR }, - }; + formattedErrors: await Promise.all( + errors.map(async (error) => { + try { + return await formatError(enrichError(error), error); + } catch (formattingError) { + if (options.includeStacktraceInErrorResponses) { + // includeStacktraceInErrorResponses is used in development + // so it will be helpful to show errors thrown by formatError hooks in that mode + return enrichError(formattingError); + } else { + // obscure error + return { + message: 'Internal server error', + extensions: { code: ApolloServerErrorCode.INTERNAL_SERVER_ERROR }, + }; + } } - } - }), + }), + ), }; function enrichError(maybeError: unknown): GraphQLFormattedError { diff --git a/packages/server/src/externalTypes/constructor.ts b/packages/server/src/externalTypes/constructor.ts index 6d6f354f655..3758d20fc9c 100644 --- a/packages/server/src/externalTypes/constructor.ts +++ b/packages/server/src/externalTypes/constructor.ts @@ -81,7 +81,7 @@ interface ApolloServerOptionsBase { formatError?: ( formattedError: GraphQLFormattedError, error: unknown, - ) => GraphQLFormattedError; + ) => GraphQLFormattedError | Promise; rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown; validationRules?: Array; fieldResolver?: GraphQLFieldResolver; diff --git a/packages/server/src/requestPipeline.ts b/packages/server/src/requestPipeline.ts index b9b4b5542dc..dea93d8e8cd 100644 --- a/packages/server/src/requestPipeline.ts +++ b/packages/server/src/requestPipeline.ts @@ -474,7 +474,7 @@ export async function processGraphQLRequest( } const { formattedErrors, httpFromErrors } = resultErrors - ? formatErrors(resultErrors) + ? await formatErrors(resultErrors) : { formattedErrors: undefined, httpFromErrors: newHTTPGraphQLHead() }; // TODO(AS5) This becomes the default behavior and the @@ -592,7 +592,7 @@ export async function processGraphQLRequest( // Note that any `http` extensions in errors have no // effect, because we've already sent the status code // and response headers. - errors: formatErrors(errors).formattedErrors, + errors: (await formatErrors(errors)).formattedErrors, }; } return incrementalResult; @@ -654,7 +654,7 @@ export async function processGraphQLRequest( ): Promise { await didEncounterErrors(errors); - const { formattedErrors, httpFromErrors } = formatErrors(errors); + const { formattedErrors, httpFromErrors } = await formatErrors(errors); requestContext.response.body = { kind: 'single',