Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow formatError to be an async function #7918

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/tall-shoes-serve.md
Original file line number Diff line number Diff line change
@@ -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',
});
},
});
```
4 changes: 2 additions & 2 deletions packages/server/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
formatError?: (
formattedError: GraphQLFormattedError,
error: unknown,
) => GraphQLFormattedError;
) => GraphQLFormattedError | Promise<GraphQLFormattedError>;
includeStacktraceInErrorResponses: boolean;
persistedQueries?: WithRequired<PersistedQueryOptions, 'cache'>;
nodeEnv: string;
Expand Down Expand Up @@ -1136,7 +1136,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
error: unknown,
requestHead: HTTPGraphQLHead,
): Promise<HTTPGraphQLResponse> {
const { formattedErrors, httpFromErrors } = normalizeAndFormatErrors(
const { formattedErrors, httpFromErrors } = await normalizeAndFormatErrors(
[error],
{
includeStacktraceInErrorResponses:
Expand Down
54 changes: 54 additions & 0 deletions packages/server/src/__tests__/ApolloServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 46 additions & 33 deletions packages/server/src/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 },
Expand All @@ -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');
});

Expand Down Expand Up @@ -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');
Expand All @@ -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(`
Expand Down
42 changes: 22 additions & 20 deletions packages/server/src/errorNormalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,43 @@
// are removed from the formatted error.
//
// This function should not throw.
export function normalizeAndFormatErrors(
export async function normalizeAndFormatErrors(
errors: ReadonlyArray<unknown>,
options: {
formatError?: (
formattedError: GraphQLFormattedError,
error: unknown,
) => GraphQLFormattedError;
) => GraphQLFormattedError | Promise<GraphQLFormattedError>;
includeStacktraceInErrorResponses?: boolean;
} = {},
): {
): Promise<{
formattedErrors: Array<GraphQLFormattedError>;
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 {

Check warning on line 48 in packages/server/src/errorNormalize.ts

View check run for this annotation

Codecov / codecov/patch

packages/server/src/errorNormalize.ts#L47-L48

Added lines #L47 - L48 were not covered by tests
// obscure error
return {

Check warning on line 50 in packages/server/src/errorNormalize.ts

View check run for this annotation

Codecov / codecov/patch

packages/server/src/errorNormalize.ts#L50

Added line #L50 was not covered by tests
message: 'Internal server error',
extensions: { code: ApolloServerErrorCode.INTERNAL_SERVER_ERROR },
};
}
}
}
}),
}),
),
};

function enrichError(maybeError: unknown): GraphQLFormattedError {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/externalTypes/constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
formatError?: (
formattedError: GraphQLFormattedError,
error: unknown,
) => GraphQLFormattedError;
) => GraphQLFormattedError | Promise<GraphQLFormattedError>;
rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown;
validationRules?: Array<ValidationRule>;
fieldResolver?: GraphQLFieldResolver<any, TContext>;
Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/requestPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ export async function processGraphQLRequest<TContext extends BaseContext>(
}

const { formattedErrors, httpFromErrors } = resultErrors
? formatErrors(resultErrors)
? await formatErrors(resultErrors)
: { formattedErrors: undefined, httpFromErrors: newHTTPGraphQLHead() };

// TODO(AS5) This becomes the default behavior and the
Expand Down Expand Up @@ -592,7 +592,7 @@ export async function processGraphQLRequest<TContext extends BaseContext>(
// 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;
Expand Down Expand Up @@ -654,7 +654,7 @@ export async function processGraphQLRequest<TContext extends BaseContext>(
): Promise<GraphQLResponse> {
await didEncounterErrors(errors);

const { formattedErrors, httpFromErrors } = formatErrors(errors);
const { formattedErrors, httpFromErrors } = await formatErrors(errors);

requestContext.response.body = {
kind: 'single',
Expand Down