diff --git a/.changeset/gentle-shirts-relate.md b/.changeset/gentle-shirts-relate.md new file mode 100644 index 00000000000..81f2abe4378 --- /dev/null +++ b/.changeset/gentle-shirts-relate.md @@ -0,0 +1,7 @@ +--- +'@apollo/server': patch +--- + +Improve bad 'accept' header error message + +It's not obvious that users can bypass content-type negotiation within Apollo Server if they want to use a content-type that isn't exactly one of the two we prescribe. This improves the error message so that users understand how to skip AS's negotiation step if they choose to use a custom content-type. diff --git a/packages/server/src/__tests__/ApolloServer.test.ts b/packages/server/src/__tests__/ApolloServer.test.ts index 0656e902644..ee6a5577922 100644 --- a/packages/server/src/__tests__/ApolloServer.test.ts +++ b/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 { @@ -687,3 +687,72 @@ it('TypedQueryDocumentNode', async () => { // that. } }); + +describe('content-type negotiation', () => { + it('responds with a BadRequest error with custom `accept` header', async () => { + const server = new ApolloServer({ + typeDefs, + resolvers, + }); + await server.start(); + + const { body } = await server.executeHTTPGraphQLRequest({ + httpGraphQLRequest: { + body: { query: '{ hello }' }, + headers: new HeaderMap([ + ['accept', 'application/json;v=1'], + ['content-type', 'application/json'], + ]), + method: 'POST', + search: '', + }, + context: async () => ({ foo: 'bla' }), + }); + assert(body.kind === 'complete'); + const result = JSON.parse(body.string); + expect(result.errors).toMatchInlineSnapshot(` + [ + { + "extensions": { + "code": "BAD_REQUEST", + }, + "message": "An 'accept' header was provided for this request which does not accept application/json; charset=utf-8 or application/graphql-response+json; charset=utf-8. If you'd like to use a custom content-type and bypass content-type negotiation altogether, set the \`content-type\` response header in a plugin.", + }, + ] + `); + await server.stop(); + }); + + it('permits a custom `accept` header when the `content-type` response header is set', async () => { + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ + { + async requestDidStart({ response }) { + response.http?.headers.set('content-type', 'application/json;v=1'); + }, + }, + ], + }); + await server.start(); + + const { body } = await server.executeHTTPGraphQLRequest({ + httpGraphQLRequest: { + body: { query: '{ hello }' }, + headers: new HeaderMap([ + ['accept', 'application/json;v=1'], + ['content-type', 'application/json'], + ]), + method: 'POST', + search: '', + }, + context: async () => ({ foo: 'bla' }), + }); + assert(body.kind === 'complete'); + const result = JSON.parse(body.string); + expect(result.errors).toBeUndefined(); + expect(result.data?.hello).toBe('world'); + await server.stop(); + }); +}); diff --git a/packages/server/src/runHttpQuery.ts b/packages/server/src/runHttpQuery.ts index b47684c3ca5..b2be91ef299 100644 --- a/packages/server/src/runHttpQuery.ts +++ b/packages/server/src/runHttpQuery.ts @@ -248,7 +248,9 @@ export async function runHttpQuery({ if (contentType === null) { throw new BadRequestError( `An 'accept' header was provided for this request which does not accept ` + - `${MEDIA_TYPES.APPLICATION_JSON} or ${MEDIA_TYPES.APPLICATION_GRAPHQL_RESPONSE_JSON}`, + `${MEDIA_TYPES.APPLICATION_JSON} or ${MEDIA_TYPES.APPLICATION_GRAPHQL_RESPONSE_JSON}. ` + + `If you'd like to use a custom content-type and bypass content-type ` + + `negotiation altogether, set the \`content-type\` response header in a plugin.`, // Use 406 Not Accepted { extensions: { http: { status: 406 } } }, );