Skip to content

Commit

Permalink
feat: allow stringifyResult to return a Promise<string> (#7803)
Browse files Browse the repository at this point in the history
* allow `stringifyResult` to return a `Promise<string>`

* call `stringifyResult` in previously missed error response cases as well
Users who implemented the `stringifyResult` hook can now expect error responses to be formatted with the hook as well. Please take care when updating to this version to ensure this is the desired behavior, or implement the desired behavior accordingly in your `stringifyResult` hook. This was considered a non-breaking change as we consider that it was an oversight in the original PR that introduced `stringifyResult` hook.
  • Loading branch information
favna committed Dec 18, 2023
1 parent 9bd7748 commit e9a0d6e
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 20 deletions.
7 changes: 7 additions & 0 deletions .changeset/thirty-hairs-change.md
@@ -0,0 +1,7 @@
---
"@apollo/server": minor
---

allow `stringifyResult` to return a `Promise<string>`

Users who implemented the `stringifyResult` hook can now expect error responses to be formatted with the hook as well. Please take care when updating to this version to ensure this is the desired behavior, or implement the desired behavior accordingly in your `stringifyResult` hook. This was considered a non-breaking change as we consider that it was an oversight in the original PR that introduced `stringifyResult` hook.
18 changes: 10 additions & 8 deletions packages/server/src/ApolloServer.ts
Expand Up @@ -180,7 +180,9 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
// flip default behavior.
status400ForVariableCoercionErrors?: boolean;
__testing_incrementalExecutionResults?: GraphQLExperimentalIncrementalExecutionResults;
stringifyResult: (value: FormattedExecutionResult) => string;
stringifyResult: (
value: FormattedExecutionResult,
) => string | Promise<string>;
}

function defaultLogger(): Logger {
Expand Down Expand Up @@ -1015,7 +1017,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
// This is typically either the masked error from when background startup
// failed, or related to invoking this function before startup or
// during/after shutdown (due to lack of draining).
return this.errorResponse(error, httpGraphQLRequest);
return await this.errorResponse(error, httpGraphQLRequest);
}

if (
Expand All @@ -1031,7 +1033,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
} catch (maybeError: unknown) {
const error = ensureError(maybeError);
this.logger.error(`Landing page \`html\` function threw: ${error}`);
return this.errorResponse(error, httpGraphQLRequest);
return await this.errorResponse(error, httpGraphQLRequest);
}
}

Expand Down Expand Up @@ -1076,7 +1078,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
// If some random function threw, add a helpful prefix when converting
// to GraphQLError. If it was already a GraphQLError, trust that the
// message was chosen thoughtfully and leave off the prefix.
return this.errorResponse(
return await this.errorResponse(
ensureGraphQLError(error, 'Context creation failed: '),
httpGraphQLRequest,
);
Expand Down Expand Up @@ -1108,14 +1110,14 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
);
}
}
return this.errorResponse(maybeError, httpGraphQLRequest);
return await this.errorResponse(maybeError, httpGraphQLRequest);
}
}

private errorResponse(
private async errorResponse(
error: unknown,
requestHead: HTTPGraphQLHead,
): HTTPGraphQLResponse {
): Promise<HTTPGraphQLResponse> {
const { formattedErrors, httpFromErrors } = normalizeAndFormatErrors(
[error],
{
Expand Down Expand Up @@ -1144,7 +1146,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
]),
body: {
kind: 'complete',
string: prettyJSONStringify({
string: await this.internals.stringifyResult({
errors: formattedErrors,
}),
},
Expand Down
114 changes: 104 additions & 10 deletions packages/server/src/__tests__/ApolloServer.test.ts
@@ -1,22 +1,22 @@
import { ApolloServer, HeaderMap } from '..';
import type { ApolloServerOptions } from '..';
import type { GatewayInterface } from '@apollo/server-gateway-interface';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { describe, expect, it, jest } from '@jest/globals';
import assert from 'assert';
import {
FormattedExecutionResult,
GraphQLError,
GraphQLSchema,
parse,
TypedQueryDocumentNode,
parse,
} from 'graphql';
import gql from 'graphql-tag';
import type { ApolloServerOptions } from '..';
import { ApolloServer, HeaderMap } from '..';
import type { ApolloServerPlugin, BaseContext } from '../externalTypes';
import type { GraphQLResponseBody } from '../externalTypes/graphql';
import { ApolloServerPluginCacheControlDisabled } from '../plugin/disabled/index.js';
import { ApolloServerPluginUsageReporting } from '../plugin/usageReporting/index.js';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { mockLogger } from './mockLogger.js';
import gql from 'graphql-tag';
import type { GatewayInterface } from '@apollo/server-gateway-interface';
import { jest, describe, it, expect } from '@jest/globals';
import type { GraphQLResponseBody } from '../externalTypes/graphql';
import assert from 'assert';

const typeDefs = gql`
type Query {
Expand Down Expand Up @@ -176,6 +176,100 @@ describe('ApolloServer construction', () => {
`);
await server.stop();
});

it('async stringifyResult', async () => {
const server = new ApolloServer({
typeDefs,
resolvers,
stringifyResult: async (value: FormattedExecutionResult) => {
let result = await Promise.resolve(
JSON.stringify(value, null, 10000),
);
result = result.replace('world', 'stringifyResults works!'); // replace text with something custom
return result;
},
});

await server.start();

const request = {
httpGraphQLRequest: {
method: 'POST',
headers: new HeaderMap([['content-type', 'application-json']]),
body: { query: '{ hello }' },
search: '',
},
context: async () => ({}),
};

const { body } = await server.executeHTTPGraphQLRequest(request);
assert(body.kind === 'complete');
expect(body.string).toMatchInlineSnapshot(`
"{
"data": {
"hello": "stringifyResults works!"
}
}"
`);
await server.stop();
});

it('throws the custom parsed error from stringifyResult', async () => {
const server = new ApolloServer({
typeDefs,
resolvers,
stringifyResult: (_: FormattedExecutionResult) => {
throw new Error('A custom synchronous error');
},
});

await server.start();

const request = {
httpGraphQLRequest: {
method: 'POST',
headers: new HeaderMap([['content-type', 'application-json']]),
body: { query: '{ error }' },
search: '',
},
context: async () => ({}),
};

await expect(
server.executeHTTPGraphQLRequest(request),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"A custom synchronous error"`,
);

await server.stop();
});

it('throws the custom parsed error from async stringifyResult', async () => {
const server = new ApolloServer({
typeDefs,
resolvers,
stringifyResult: async (_: FormattedExecutionResult) =>
Promise.reject('A custom asynchronous error'),
});

await server.start();

const request = {
httpGraphQLRequest: {
method: 'POST',
headers: new HeaderMap([['content-type', 'application-json']]),
body: { query: '{ error }' },
search: '',
},
context: async () => ({}),
};

await expect(
server.executeHTTPGraphQLRequest(request),
).rejects.toMatchInlineSnapshot(`"A custom asynchronous error"`);

await server.stop();
});
});

it('throws when an API key is not a valid header value', () => {
Expand Down Expand Up @@ -501,7 +595,7 @@ describe('ApolloServer executeOperation', () => {

const { body, http } = await server.executeOperation({
query: `#graphql
query NeedsArg($arg: CompoundInput!) { needsCompoundArg(aCompound: $arg) }
query NeedsArg($arg: CompoundInput!) { needsCompoundArg(aCompound: $arg) }
`,
// @ts-expect-error for `null` case
variables,
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/externalTypes/constructor.ts
Expand Up @@ -89,7 +89,9 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
includeStacktraceInErrorResponses?: boolean;
logger?: Logger;
allowBatchedHttpRequests?: boolean;
stringifyResult?: (value: FormattedExecutionResult) => string;
stringifyResult?: (
value: FormattedExecutionResult,
) => string | Promise<string>;
introspection?: boolean;
plugins?: ApolloServerPlugin<TContext>[];
persistedQueries?: PersistedQueryOptions | false;
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/runHttpQuery.ts
Expand Up @@ -260,7 +260,7 @@ export async function runHttpQuery<TContext extends BaseContext>({
...graphQLResponse.http,
body: {
kind: 'complete',
string: internals.stringifyResult(
string: await internals.stringifyResult(
orderExecutionResultFields(graphQLResponse.body.singleResult),
),
},
Expand Down

0 comments on commit e9a0d6e

Please sign in to comment.